Skip to content

Commit

Permalink
Merge pull request #361 from auth0/feat-org
Browse files Browse the repository at this point in the history
Add support for Organizations [SDK-2398]
  • Loading branch information
lbalmaceda authored Mar 26, 2021
2 parents 15a12d5 + 0b846a3 commit 3fad40e
Show file tree
Hide file tree
Showing 6 changed files with 199 additions and 0 deletions.
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,49 @@ auth0

For more info please check our generated [documentation](http://auth0.github.io/react-native-auth0/index.html)

### Organizations (Closed Beta)

Organizations is a set of features that provide better support for developers who build and maintain SaaS and Business-to-Business (B2B) applications.

Using Organizations, you can:

- Represent teams, business customers, partner companies, or any logical grouping of users that should have different ways of accessing your applications, as organizations.
- Manage their membership in a variety of ways, including user invitation.
- Configure branded, federated login flows for each organization.
- Implement role-based access control, such that users can have different roles when authenticating in the context of different organizations.
- Build administration capabilities into your products, using Organizations APIs, so that those businesses can manage their own organizations.

Note that Organizations is currently only available to customers on our Enterprise and Startup subscription plans.

#### Log in to an organization

```js
auth0.webAuth
.authorize({organization: 'organization-id'})
.then(credentials => console.log(credentials))
.catch(error => console.log(error));
```

#### Accept user invitations

Users can be invited to your organization via a link. Tapping on the invitation link should open your app. Since invitations links are `https` only, is recommended that your Android app supports [Android App Links](https://developer.android.com/training/app-links). In the case of iOS, your app must support [Universal Links](https://developer.apple.com/documentation/xcode/allowing_apps_and_websites_to_link_to_your_content/supporting_universal_links_in_your_app).

In [Enable Android App Links Support](https://auth0.com/docs/applications/enable-android-app-links-support) and [Enable Universal Links Support](https://auth0.com/docs/enable-universal-links-support-in-apple-xcode), you will find how to make the Auth0 server publish the Digital Asset Links file required by your applications.

When your app gets opened by an invitation link, grab the invitation URL and pass it as a parameter to the webauth call. Use the [Linking Module](https://reactnative.dev/docs/linking) method called `getInitialUrl()` to obtain the URL that launched your application.

```js
auth0.webAuth
.authorize({
invitationUrl:
'https://myapp.com/login?invitation=inv123&organization=org123',
})
.then(credentials => console.log(credentials))
.catch(error => console.log(error));
```

If the URL doesn't contain the expected values, an error will be raised through the provided callback.

### Bot Protection

If you are using the [Bot Protection](https://auth0.com/docs/anomaly-detection/bot-protection) feature and performing database login/signup via the Authentication API, you need to handle the `requires_verification` error. It indicates that the request was flagged as suspicious and an additional verification step is necessary to log the user in. That verification step is web-based, so you need to use Universal Login to complete it.
Expand Down
39 changes: 39 additions & 0 deletions src/jwt/__tests__/jwt.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,45 @@ describe('id token verification tests', () => {
).rejects.toHaveProperty('name', 'a0.idtoken.invalid_nonce_claim');
});

it('fails when "organization" sent on authentication request but "org_id" is missing from token claims', async () => {
const testJwt =
'eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3NDg2ODAwLCJpYXQiOjE1NjczMTQwMDAsImF6cCI6InRva2Vucy10ZXN0LTEyMyIsImF1dGhfdGltZSI6MTU2NzMxNDAwMCwibm9uY2UiOiJhNTl2azU5MiJ9.nfnx9bMtDJa4EuGMMps5-Yh_Ma-in6k9bzVEcQT648g';

setupSignatureMock(testJwt);

await expect(
verify(testJwt, {
orgId: 'org-test',
}),
).rejects.toHaveProperty('name', 'a0.idtoken.missing_org_id_claim');
});

it('fails when "organization" sent on authentication request but "org_id" claim is invalid', async () => {
const testJwt =
'eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3NDg2ODAwLCJpYXQiOjE1NjczMTQwMDAsIm9yZ19pZCI6InRlc3Qtb3JnIiwiYXpwIjoidG9rZW5zLXRlc3QtMTIzIiwiYXV0aF90aW1lIjoxNTY3MzE0MDAwLCJub25jZSI6ImE1OXZrNTkyIn0.mP50Orcdc-n-tUm_MqKKbpiC0hi9Gh2j_eUaIOHo8qA';

setupSignatureMock(testJwt);

await expect(
verify(testJwt, {
orgId: 'nope',
}),
).rejects.toHaveProperty('name', 'a0.idtoken.invalid_org_id_claim');
});

it('succeeds when "organization" sent on authentication request matches the received "org_id" claim', async () => {
const testJwt =
'eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3NDg2ODAwLCJpYXQiOjE1NjczMTQwMDAsIm9yZ19pZCI6InRlc3Qtb3JnIiwiYXpwIjoidG9rZW5zLXRlc3QtMTIzIiwiYXV0aF90aW1lIjoxNTY3MzE0MDAwLCJub25jZSI6ImE1OXZrNTkyIn0.mP50Orcdc-n-tUm_MqKKbpiC0hi9Gh2j_eUaIOHo8qA';

setupSignatureMock(testJwt);

await expect(
verify(testJwt, {
orgId: 'test-org',
}),
).resolves.toBeUndefined();
});

it('fails when "aud" is array with multiple items, and "azp" is missing', async () => {
const testJwt =
'eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3NDg2ODAwLCJpYXQiOjE1NjczMTQwMDAsIm5vbmNlIjoiYTU5dms1OTIiLCJhdXRoX3RpbWUiOjE1NjczMTQwMDB9.ab1cp7PTjoRNQwlJ6-ENjmFmxuoKtDHOzgB_3YiCxsIVN3WqSgv-l-AonhvnTg8qV1YXArYXlRxkE7IeXVTgB6981cHhaOywQJgZ_8NeNN7eMOyTVlcmQBP-1Ar2-Hgb8RKjNVFb-rMOGqhn2B9yu_E5amSGyPzHrATQ1wcfO-XSuzYdCbTokurEA2LsE8Sr4eMMUlRLNLjBSy-aLmIyggFOKkvw1qCiJq28tBfI24p0Al0NLfyS3EbimJIqk6JIMyOh40sdlxi9wrVt6iUjbxhN6xYA2JYBXHjMmF8l4xWPL4I4aX5g-5vpj_w10A0kepvFDhw_EbKpR-XqZ-GW3Q';
Expand Down
21 changes: 21 additions & 0 deletions src/jwt/validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,27 @@ const validateClaims = (payload, opts) => {
}
}

// Organization ID
if (opts.orgId) {
if (typeof payload.org_id !== 'string') {
return Promise.reject(
idTokenError({
error: 'missing_org_id_claim',
desc:
'Organization ID (org_id) claim must be a string present in the ID token',
}),
);
}
if (payload.org_id !== opts.orgId) {
return Promise.reject(
idTokenError({
error: 'invalid_org_id_claim',
desc: `Organization ID (org_id) claim mismatch in the ID token; expected "${opts.orgId}", found "${payload.org_id}"`,
}),
);
}
}

//Authorized party
if (Array.isArray(payload.aud) && payload.aud.length > 1) {
if (typeof payload.azp !== 'string') {
Expand Down
5 changes: 5 additions & 0 deletions src/webauth/__tests__/__snapshots__/webauth.spec.js.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`WebAuth organizations during log in should throw an error when the values are not present in the invitation URL 1`] = `[a0.invalid_invitation_url: The invitation URL provided doesn't contain the 'organization' or 'invitation' values.]`;

exports[`WebAuth organizations during log in should throw an error when the values are not present in the invitation URL 2`] = `[a0.invalid_invitation_url: The invitation URL provided doesn't contain the 'organization' or 'invitation' values.]`;
72 changes: 72 additions & 0 deletions src/webauth/__tests__/webauth.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,78 @@ describe('WebAuth', () => {
});
});

describe('organizations during log in', () => {
it('should build the authorize URL with a organization', async () => {
const newTransactionMock = jest
.spyOn(webauth.agent, 'newTransaction')
.mockImplementation(() =>
Promise.resolve({state: 'state', verifier: 'verifier'}),
);
const showMock = jest
.spyOn(webauth.agent, 'show')
.mockImplementation(authorizeUrl => ({
then: () => Promise.resolve(authorizeUrl),
}));
const parameters = {organization: 'test-org'};
let url = await webauth.authorize(parameters, {});

const parsedUrl = new URL(url);
const urlQuery = parsedUrl.searchParams;
expect(urlQuery.get('organization')).toEqual('test-org');
newTransactionMock.mockRestore();
showMock.mockRestore();
});

it('should build the authorize URL parsing organization from invitation URL', async () => {
const newTransactionMock = jest
.spyOn(webauth.agent, 'newTransaction')
.mockImplementation(() =>
Promise.resolve({state: 'state', verifier: 'verifier'}),
);
const showMock = jest
.spyOn(webauth.agent, 'show')
.mockImplementation(authorizeUrl => ({
then: () => Promise.resolve(authorizeUrl),
}));
const parameters = {
invitationUrl:
'https://myapp.com/?invitation=inv123&organization=org123',
};
let url = await webauth.authorize(parameters, {});

const parsedUrl = new URL(url);
const urlQuery = parsedUrl.searchParams;
expect(urlQuery.get('organization')).toEqual('org123');
expect(urlQuery.get('invitation')).toEqual('inv123');
newTransactionMock.mockRestore();
showMock.mockRestore();
});

it('should throw an error when the values are not present in the invitation URL', async () => {
const newTransactionMock = jest
.spyOn(webauth.agent, 'newTransaction')
.mockImplementation(() =>
Promise.resolve({state: 'state', verifier: 'verifier'}),
);
const showMock = jest
.spyOn(webauth.agent, 'show')
.mockImplementation(authorizeUrl => ({
then: () => Promise.resolve(authorizeUrl),
}));

expect.assertions(2);
const parameters = {
invitationUrl: 'https://myapp.com/?invitation=inv123',
};
await expect(webauth.authorize(parameters, {})).rejects.toMatchSnapshot();
parameters.invitationUrl = 'https://myapp.com/?organization=org123';
await expect(webauth.authorize(parameters, {})).rejects.toMatchSnapshot();

newTransactionMock.mockRestore();
showMock.mockRestore();
});
});

describe('custom scheme', () => {
it('should build the callback URL with a custom scheme when logging in', async () => {
const newTransactionMock = jest
Expand Down
19 changes: 19 additions & 0 deletions src/webauth/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export default class WebAuth {
* @param {String} [parameters.scope] Scopes requested for the issued tokens. e.g. `openid profile`
* @param {String} [parameters.connection] The name of the identity provider to use, e.g. "google-oauth2" or "facebook". When not set, it will display Auth0's Universal Login Page.
* @param {Number} [parameters.max_age] The allowable elapsed time in seconds since the last time the user was authenticated (optional).
* @param {String} [parameters.organization] the ID of the organization to join
* @param {String} [parameters.invitationUrl] the invitation URL to join an organization. Takes precedence over the "organization" parameter.
* @param {Object} options Other configuration options.
* @param {Number} [options.leeway] The amount of leeway, in seconds, to accommodate potential clock skew when validating an ID token's claims. Defaults to 60 seconds if not specified.
* @param {Boolean} [options.ephemeralSession] Disable Single-Sign-On (SSO). It only affects iOS with versions 13 and above.
Expand All @@ -68,6 +70,22 @@ export default class WebAuth {
return agent.newTransaction().then(({state, verifier, ...defaults}) => {
const redirectUri = callbackUri(domain, options.customScheme);
const expectedState = parameters.state || state;
if (parameters.invitationUrl) {
const urlQuery = url.parse(parameters.invitationUrl, true).query;
const {invitation, organization} = urlQuery;
if (!invitation || !organization) {
throw new AuthError({
json: {
error: 'a0.invalid_invitation_url',
error_description: `The invitation URL provided doesn't contain the 'organization' or 'invitation' values.`,
},
status: 0,
});
}
parameters.invitation = invitation;
parameters.organization = organization;
}

let query = {
...defaults,
clientId,
Expand Down Expand Up @@ -114,6 +132,7 @@ export default class WebAuth {
maxAge: parameters.max_age,
scope: parameters.scope,
leeway: options.leeway,
orgId: parameters.organization,
}).then(() => Promise.resolve(credentials));
});
});
Expand Down

0 comments on commit 3fad40e

Please sign in to comment.