diff --git a/README.md b/README.md index 1da53e99..70f62610 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,61 @@ In addition to the Management API, this SDK also provides access to [Authenticat Please note that this module implements endpoints that might be deprecated for newer tenants. If you have any questions about how and when the endpoints should be used, consult the [documentation](https://auth0.com/docs/api/authentication) or ask in our [Community forums](https://community.auth0.com/tags/wordpress). +### 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. + +#### Logging in with an Organization + +Configure the Authentication API client and pass your Organization ID to the authorize url: + +```ruby +require 'auth0' + +@auth0_client ||= Auth0Client.new( + client_id: '{YOUR_APPLICATION_CLIENT_ID}', + client_secret: '{YOUR_APPLICATION_CLIENT_SECRET}', + domain: '{YOUR_TENANT}.auth0.com' +) + +universal_login_url = @auth0_client.authorization_url("https://{YOUR_APPLICATION_CALLBACK_URL}", { + organization: "{YOUR_ORGANIZATION_ID}", +}) + +# redirect_to universal_login_url +``` + +#### Accepting user invitations + +Auth0 Organizations allow users to be invited using emailed links, which will direct a user back to your application. The URL the user will arrive at is based on your configured `Application Login URI`, which you can change from your Application's settings inside the Auth0 dashboard. When they arrive at this URL, a `invitation` and `organization` query parameters will be provided + +```ruby +require 'auth0' + +@auth0_client ||= Auth0Client.new( + client_id: '{YOUR_APPLICATION_CLIENT_ID}', + client_secret: '{YOUR_APPLICATION_CLIENT_ID}', + domain: '{YOUR_TENANT}.auth0.com' +) + +universal_login_url = @auth0_client.authorization_url("https://{YOUR_APPLICATION_CALLBACK_URL}", { + organization: "{ORGANIZATION_QUERY_PARAM}", + invitation: "{INVITATION_QUERY_PARAM}" +}) + +# redirect_to universal_login_url +``` + ## ID Token Validation An ID token may be present in the credentials received after authentication. This token contains information associated with the user that has just logged in, provided the scope used contained `openid`. You can [read more about ID tokens here](https://auth0.com/docs/tokens/concepts/id-tokens). diff --git a/lib/auth0/api/authentication_endpoints.rb b/lib/auth0/api/authentication_endpoints.rb index 64b928a5..30442fa1 100644 --- a/lib/auth0/api/authentication_endpoints.rb +++ b/lib/auth0/api/authentication_endpoints.rb @@ -14,18 +14,24 @@ module AuthenticationEndpoints # Request an API access token using a Client Credentials grant # @see https://auth0.com/docs/api-auth/tutorials/client-credentials # @param audience [string] API audience to use + # @param organization [string] Organization ID # @return [json] Returns the API token def api_token( client_id: @client_id, client_secret: @client_secret, - audience: "https://#{@domain}/api/v2/" + audience: "https://#{@domain}/api/v2/", + organization: '' ) + request_params = { grant_type: 'client_credentials', client_id: client_id, client_secret: client_secret, audience: audience } + + request_params[:organization] = organization if !organization.empty? + response = post('/oauth/token', request_params) ::Auth0::ApiToken.new(response['access_token'], response['scope'], response['expires_in']) end @@ -220,7 +226,7 @@ def userinfo(access_token) # Return an authorization URL. # @see https://auth0.com/docs/api/authentication#authorization-code-grant # @param redirect_uri [string] URL to redirect after authorization - # @param options [hash] Can contain response_type, connection, state and additional_parameters. + # @param options [hash] Can contain response_type, connection, state, organization, invitation, and additional_parameters. # @return [url] Authorization URL. def authorization_url(redirect_uri, options = {}) raise Auth0::InvalidParameter, 'Must supply a valid redirect_uri' if redirect_uri.to_s.empty? @@ -231,7 +237,9 @@ def authorization_url(redirect_uri, options = {}) connection: options.fetch(:connection, nil), redirect_uri: redirect_uri, state: options.fetch(:state, nil), - scope: options.fetch(:scope, nil) + scope: options.fetch(:scope, nil), + organization: options.fetch(:organization, nil), + invitation: options.fetch(:invitation, nil) }.merge(options.fetch(:additional_parameters, {})) URI::HTTPS.build(host: @domain, path: '/authorize', query: to_query(request_params)) diff --git a/lib/auth0/mixins/validation.rb b/lib/auth0/mixins/validation.rb index cdb4845e..514e757c 100644 --- a/lib/auth0/mixins/validation.rb +++ b/lib/auth0/mixins/validation.rb @@ -100,11 +100,13 @@ def validate_claims(claims) issuer = @context[:issuer] audience = @context[:audience] max_age = @context[:max_age] + org = @context[:organization] raise Auth0::InvalidParameter, 'Must supply a valid leeway' unless leeway.is_a?(Integer) && leeway >= 0 raise Auth0::InvalidParameter, 'Must supply a valid nonce' unless nonce.nil? || !nonce.to_s.empty? raise Auth0::InvalidParameter, 'Must supply a valid issuer' unless issuer.nil? || !issuer.to_s.empty? raise Auth0::InvalidParameter, 'Must supply a valid audience' unless audience.nil? || !audience.to_s.empty? + raise Auth0::InvalidParameter, 'Must supply a valid organization' unless org.nil? || !org.to_s.empty? unless max_age.nil? || (max_age.is_a?(Integer) && max_age >= 0) raise Auth0::InvalidParameter, 'Must supply a valid max_age' @@ -118,6 +120,7 @@ def validate_claims(claims) validate_nonce(claims, nonce) if nonce validate_azp(claims, audience) if claims['aud'].is_a?(Array) && claims['aud'].count > 1 validate_auth_time(claims, max_age, leeway) if max_age + validate_org(claims, org) if org end # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity @@ -184,6 +187,17 @@ def validate_nonce(claims, expected) end end + def validate_org(claims, expected) + unless claims.key?('org_id') && claims['org_id'].is_a?(String) + raise Auth0::InvalidIdToken, 'Organization Id (org_id) claim must be a string present in the ID token' + end + + unless expected == claims['org_id'] + raise Auth0::InvalidIdToken, "Organization Id (org_id) claim value mismatch in the ID token; expected \"#{expected}\","\ + " found \"#{claims['org_id']}\"" + end + end + def validate_azp(claims, expected) unless claims.key?('azp') && claims['azp'].is_a?(String) raise Auth0::InvalidIdToken, 'Authorized Party (azp) claim must be a string present in the ID token' diff --git a/spec/lib/auth0/mixins/validation_spec.rb b/spec/lib/auth0/mixins/validation_spec.rb index 275aaf46..237a54e0 100644 --- a/spec/lib/auth0/mixins/validation_spec.rb +++ b/spec/lib/auth0/mixins/validation_spec.rb @@ -143,6 +143,12 @@ expect { instance.validate(token) }.to raise_exception('Must supply a valid nonce') end + it 'is expected to raise an error with an empty organization' do + instance = Auth0::Mixins::Validation::IdTokenValidator.new(CONTEXT.merge({ organization: '' })) + + expect { instance.validate(token) }.to raise_exception('Must supply a valid organization') + end + it 'is expected to raise an error with an empty issuer' do instance = Auth0::Mixins::Validation::IdTokenValidator.new(CONTEXT.merge({ issuer: '' })) @@ -277,6 +283,32 @@ expect { instance.validate(token) }.to raise_exception("Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication. Current time \"#{clock}\" is after last auth at \"#{auth_time}\"") end + + it 'is expected not to raise an error when org_id exsist in the token, but not required' do + token = 'eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC05OTkiXSwiZXhwIjoxNjE2NjE3ODgxLCJpYXQiOjE2MTY0NDUwODEsIm5vbmNlIjoiYTFiMmMzZDRlNSIsImF6cCI6InRva2Vucy10ZXN0LTEyMyIsImF1dGhfdGltZSI6MTYxNjUzMTQ4MSwib3JnX2lkIjoidGVzdE9yZyJ9.AOafUKUNgaxUXpSRYFCeJERcwrQZ4q2NZlutwGXnh9I' + expect { @instance.validate(token) }.not_to raise_exception + end + + it 'is expected to raise an error with a missing but required organization' do + token = 'eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC05OTkiXSwiZXhwIjoxNjE2NjE4MTg1LCJpYXQiOjE2MTY0NDUzODUsIm5vbmNlIjoiYTFiMmMzZDRlNSIsImF6cCI6InRva2Vucy10ZXN0LTEyMyIsImF1dGhfdGltZSI6MTYxNjUzMTc4NX0.UMo5pmgceXO9lIKzbk7X0ZhE5DOe0IP2LfMKdUj03zQ' + instance = Auth0::Mixins::Validation::IdTokenValidator.new(CONTEXT.merge({ organization: 'a1b2c3d4e5' })) + + expect { instance.validate(token) }.to raise_exception('Organization Id (org_id) claim must be a string present in the ID token') + end + + it 'is expected to raise an error with an invalid organization' do + token = 'eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC05OTkiXSwiZXhwIjoxNjE2NjE3ODgxLCJpYXQiOjE2MTY0NDUwODEsIm5vbmNlIjoiYTFiMmMzZDRlNSIsImF6cCI6InRva2Vucy10ZXN0LTEyMyIsImF1dGhfdGltZSI6MTYxNjUzMTQ4MSwib3JnX2lkIjoidGVzdE9yZyJ9.AOafUKUNgaxUXpSRYFCeJERcwrQZ4q2NZlutwGXnh9I' + instance = Auth0::Mixins::Validation::IdTokenValidator.new(CONTEXT.merge({ organization: 'a1b2c3d4e5' })) + + expect { instance.validate(token) }.to raise_exception('Organization Id (org_id) claim value mismatch in the ID token; expected "a1b2c3d4e5", found "testOrg"') + end + + it 'is expected to NOT raise an error with a valid organization' do + token = 'eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC05OTkiXSwiZXhwIjoxNjE2NjE3ODgxLCJpYXQiOjE2MTY0NDUwODEsIm5vbmNlIjoiYTFiMmMzZDRlNSIsImF6cCI6InRva2Vucy10ZXN0LTEyMyIsImF1dGhfdGltZSI6MTYxNjUzMTQ4MSwib3JnX2lkIjoidGVzdE9yZyJ9.AOafUKUNgaxUXpSRYFCeJERcwrQZ4q2NZlutwGXnh9I' + instance = Auth0::Mixins::Validation::IdTokenValidator.new(CONTEXT.merge({ organization: 'testOrg' })) + + expect { instance.validate(token) }.not_to raise_exception + end end end