diff --git a/README.md b/README.md index cc44a2a3..2fbe73f9 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,51 @@ try { } ``` +### 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 + +Log in to an organization by using `withOrganization()` when building the Authorization URL: + +```java +AuthAPI auth = new AuthAPI("{YOUR_DOMAIN}", "{YOUR_CLIENT_ID}", "{YOUR_CLIENT_SECRET}"); +String url = auth.authorizeUrl("https://me.auth0.com/callback") + .withOrganization("{YOUR_ORGANIZATION_ID") + .build(); +``` + +> When logging into an organization, it is important to ensure the `org_id` claim of the ID Token matches the expected organization value. The `IdTokenVerifier` can be configured with an expected `org_id` claim value: +>```java +>IdTokenVerifier.init("{ISSUER}", "{AUDIENCE}", signatureVerifier) +> .withOrganization("{ORG_ID}") +> .build() +> .verify(jwt); +>``` + +### Accept user invitations + +Accept a user invitation by using `withInvitation()` when building the Authorization URL: + +``` +AuthAPI auth = new AuthAPI("{YOUR_DOMAIN}", "{YOUR_CLIENT_ID}", "{YOUR_CLIENT_SECRET}"); +String url = auth.authorizeUrl("https://me.auth0.com/callback") + .withOrganization("{YOUR_ORGANIZATION_ID") + .withInvitation("{YOUR_INVITATION_ID}") + .build(); +``` + ## Management API The implementation is based on the [Management API Docs](https://auth0.com/docs/api/management/v2). diff --git a/src/main/java/com/auth0/client/auth/AuthorizeUrlBuilder.java b/src/main/java/com/auth0/client/auth/AuthorizeUrlBuilder.java index 3759d209..c5847d56 100644 --- a/src/main/java/com/auth0/client/auth/AuthorizeUrlBuilder.java +++ b/src/main/java/com/auth0/client/auth/AuthorizeUrlBuilder.java @@ -105,6 +105,31 @@ public AuthorizeUrlBuilder withResponseType(String responseType) { return this; } + /** + * Sets the organization query string parameter value used to login to an organization. + * + * @param organization The ID of the organization to log the user in to. + * @return the builder instance. + */ + public AuthorizeUrlBuilder withOrganization(String organization) { + assertNotNull(organization, "organization"); + parameters.put("organization", organization); + return this; + } + + /** + * Sets the invitation query string parameter to join an organization. If using this, you must also specify the + * organization using {@linkplain AuthorizeUrlBuilder#withOrganization(String)}. + * + * @param invitation The ID of the invitation to accept. This is available on the URL that is provided when accepting an invitation. + * @return the builder instance. + */ + public AuthorizeUrlBuilder withInvitation(String invitation) { + assertNotNull(invitation, "invitation"); + parameters.put("invitation", invitation); + return this; + } + /** * Sets an additional parameter. * diff --git a/src/main/java/com/auth0/utils/tokens/IdTokenVerifier.java b/src/main/java/com/auth0/utils/tokens/IdTokenVerifier.java index d43a1ec5..aa63c5a9 100644 --- a/src/main/java/com/auth0/utils/tokens/IdTokenVerifier.java +++ b/src/main/java/com/auth0/utils/tokens/IdTokenVerifier.java @@ -28,6 +28,7 @@ public final class IdTokenVerifier { private final Integer leeway; private final Date clock; private final SignatureVerifier signatureVerifier; + private final String organization; private IdTokenVerifier(Builder builder) { this.issuer = builder.issuer; @@ -35,6 +36,7 @@ private IdTokenVerifier(Builder builder) { this.leeway = builder.leeway; this.signatureVerifier = builder.signatureVerifier; this.clock = builder.clock; + this.organization = builder.organization; } /** @@ -134,6 +136,18 @@ public void verify(String token, String nonce, Integer maxAuthenticationAge) thr throw new IdTokenValidationException(String.format("Audience (aud) claim mismatch in the ID token; expected \"%s\" but found \"%s\"", this.audience, decoded.getAudience())); } + // Org verification + if (this.organization != null) { + String orgClaim = decoded.getClaim("org_id").asString(); + if (isEmpty(orgClaim)) { + throw new IdTokenValidationException("Organization Id (org_id) claim must be a string present in the ID token"); + } + if (!this.organization.equals(orgClaim)) { + throw new IdTokenValidationException(String.format("Organization (org_id) claim mismatch in the ID token; expected \"%s\" but found \"%s\"", this.organization, orgClaim)); + } + + } + final Calendar cal = Calendar.getInstance(); final Date now = this.clock != null ? this.clock : cal.getTime(); final int clockSkew = this.leeway != null ? this.leeway : DEFAULT_LEEWAY; @@ -209,6 +223,7 @@ public static class Builder { private Integer leeway; private Date clock; + private String organization; /** * Create a new Builder instance. @@ -251,6 +266,18 @@ Builder withClock(Date clock) { return this; } + /** + * Specify the expected organization (org_id) the token must be issued for. This should be used if using the + * Organizations feature. + + * @param organization the ID of the organization. + * @return this Builder instance. + */ + Builder withOrganization(String organization) { + this.organization = organization; + return this; + } + /** * Constructs an {@linkplain IdTokenVerifier} instance from this Builder. * diff --git a/src/test/java/com/auth0/client/auth/AuthorizeUrlBuilderTest.java b/src/test/java/com/auth0/client/auth/AuthorizeUrlBuilderTest.java index b93ef5aa..2e9b5ac7 100644 --- a/src/test/java/com/auth0/client/auth/AuthorizeUrlBuilderTest.java +++ b/src/test/java/com/auth0/client/auth/AuthorizeUrlBuilderTest.java @@ -192,4 +192,38 @@ public void shouldThrowWhenCustomParameterValueIsNull() { AuthorizeUrlBuilder.newInstance(DOMAIN, CLIENT_ID, REDIRECT_URI) .withParameter("name", null); } + + @Test + public void shouldAddOrganizationParameter() { + String authUrl = AuthorizeUrlBuilder.newInstance(DOMAIN, CLIENT_ID, REDIRECT_URI) + .withOrganization("org_abc") + .build(); + assertThat(authUrl, hasQueryParameter("organization", "org_abc")); + } + + @Test + public void shouldThrowWhenOrganizationIsNull() { + exception.expect(IllegalArgumentException.class); + exception.expectMessage("'organization' cannot be null!"); + AuthorizeUrlBuilder.newInstance(DOMAIN, CLIENT_ID, REDIRECT_URI) + .withOrganization(null) + .build(); + } + + @Test + public void shouldAddInvitationParameter() { + String authUrl = AuthorizeUrlBuilder.newInstance(DOMAIN, CLIENT_ID, REDIRECT_URI) + .withInvitation("invitation_123") + .build(); + assertThat(authUrl, hasQueryParameter("invitation", "invitation_123")); + } + + @Test + public void shouldThrowWhenInvitationIsNull() { + exception.expect(IllegalArgumentException.class); + exception.expectMessage("'invitation' cannot be null!"); + AuthorizeUrlBuilder.newInstance(DOMAIN, CLIENT_ID, REDIRECT_URI) + .withInvitation(null) + .build(); + } } diff --git a/src/test/java/com/auth0/utils/tokens/IdTokenVerifierTest.java b/src/test/java/com/auth0/utils/tokens/IdTokenVerifierTest.java index 5fd51177..7df1c550 100644 --- a/src/test/java/com/auth0/utils/tokens/IdTokenVerifierTest.java +++ b/src/test/java/com/auth0/utils/tokens/IdTokenVerifierTest.java @@ -447,6 +447,108 @@ public void succeedsWithValidTokenUsingDefaultClockAndHttpsDomain() { .verify(token, "nonce"); } + @Test + public void succeedsWhenOrganizationMatchesExpected() { + String token = JWT.create() + .withSubject("auth0|sdk458fks") + .withAudience(AUDIENCE) + .withIssuedAt(getYesterday()) + .withExpiresAt(getTomorrow()) + .withIssuer("https://" + DOMAIN + "/") + .withClaim("org_id", "org_123") + .sign(Algorithm.HMAC256("secret")); + + String jwt = JWT.decode(token).getToken(); + + configureVerifier(jwt) + .withOrganization("org_123") + .build() + .verify(jwt); + } + + @Test + public void failsWhenOrganizationDoesNotMatchExpected() { + exception.expect(IdTokenValidationException.class); + exception.expectMessage("Organization (org_id) claim mismatch in the ID token; expected \"org_abc\" but found \"org_123\""); + + String token = JWT.create() + .withSubject("auth0|sdk458fks") + .withAudience(AUDIENCE) + .withIssuedAt(getYesterday()) + .withExpiresAt(getTomorrow()) + .withIssuer("https://" + DOMAIN + "/") + .withClaim("org_id", "org_123") + .sign(Algorithm.HMAC256("secret")); + + String jwt = JWT.decode(token).getToken(); + + configureVerifier(jwt) + .withOrganization("org_abc") + .build() + .verify(jwt); + } + + @Test + public void failsWhenOrganizationExpectedButNotPresent() { + exception.expect(IdTokenValidationException.class); + exception.expectMessage("Organization Id (org_id) claim must be a string present in the ID token"); + + String token = JWT.create() + .withSubject("auth0|sdk458fks") + .withAudience(AUDIENCE) + .withIssuedAt(getYesterday()) + .withExpiresAt(getTomorrow()) + .withIssuer("https://" + DOMAIN + "/") + .sign(Algorithm.HMAC256("secret")); + + String jwt = JWT.decode(token).getToken(); + + configureVerifier(jwt) + .withOrganization("org_123") + .build() + .verify(jwt); + } + + @Test + public void failsWhenOrganizationExpectedButClaimIsNotString() { + exception.expect(IdTokenValidationException.class); + exception.expectMessage("Organization Id (org_id) claim must be a string present in the ID token"); + + String token = JWT.create() + .withSubject("auth0|sdk458fks") + .withAudience(AUDIENCE) + .withIssuedAt(getYesterday()) + .withExpiresAt(getTomorrow()) + .withIssuer("https://" + DOMAIN + "/") + .withClaim("org_id", 42) + .sign(Algorithm.HMAC256("secret")); + + String jwt = JWT.decode(token).getToken(); + + configureVerifier(jwt) + .withOrganization("org_123") + .build() + .verify(jwt); + } + + @Test + public void succeedsWhenOrganizationNotSpecifiedButIsPresent() { + String token = JWT.create() + .withSubject("auth0|sdk458fks") + .withAudience(AUDIENCE) + .withIssuedAt(getYesterday()) + .withExpiresAt(getTomorrow()) + .withIssuer("https://" + DOMAIN + "/") + .withClaim("org_id", "org_123") + .sign(Algorithm.HMAC256("secret")); + + String jwt = JWT.decode(token).getToken(); + + configureVerifier(jwt) + .build() + .verify(jwt); + } + private IdTokenVerifier.Builder configureVerifier(String token) { DecodedJWT decodedJWT = JWT.decode(token); SignatureVerifier verifier = mock(SignatureVerifier.class); @@ -469,4 +571,4 @@ private Date getTomorrow() { return cal.getTime(); } -} \ No newline at end of file +}