diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a29618e..77db143 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { "name": "Ruby", - "image": "mcr.microsoft.com/devcontainers/ruby:3.1", + "image": "mcr.microsoft.com/devcontainers/ruby:3.2", "features": { "ghcr.io/devcontainers/features/node:1": { "version": "lts" diff --git a/EXAMPLES.md b/EXAMPLES.md index b36d5d0..6cee612 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -125,25 +125,38 @@ When passing `openid` to the scope and `organization` to the authorize params, y ### Validating Organizations when using Organization Login Prompt -When Organization login prompt is enabled on your application, but you haven't specified an Organization for the application's authorization endpoint, the `org_id` claim will be present on the ID token, and should be validated to ensure that the value received is expected or known. +When Organization login prompt is enabled on your application, but you haven't specified an Organization for the application's authorization endpoint, `org_id` or `org_name` claims will be present on the ID and access tokens, and should be validated to ensure that the value received is expected or known. Normally, validating the issuer would be enough to ensure that the token was issued by Auth0, and this check is performed by the SDK. However, in the case of organizations, additional checks should be made so that the organization within an Auth0 tenant is expected. -In particular, the `org_id` claim should be checked to ensure it is a value that is already known to the application. This could be validated against a known list of organization IDs, or perhaps checked in conjunction with the current request URL. e.g. the sub-domain may hint at what organization should be used to validate the ID Token. +In particular, the `org_id` and `org_name` claims should be checked to ensure it is a value that is already known to the application. This could be validated against a known list of organization IDs, or perhaps checked in conjunction with the current request URL. e.g. the sub-domain may hint at what organization should be used to validate the ID Token. For `org_id`, this should be a **case-sensitive, exact match check**. For `org_name`, this should be a **case-insentive check**. + +The decision to validate the `org_id` or `org_name` claim is determined by the expected organization ID or name having an `org_` prefix. Here is an example using it in your `callback` method ```ruby - def callback - claims = request.env['omniauth.auth']['extra']['raw_info'] +def callback + claims = request.env['omniauth.auth']['extra']['raw_info'] + + validate_as_id = expected_org.start_with?('org_') - if claims["org"] && claims["org"] !== expected_org + if validate_as_id + if claims["org_id"] && claims["org_id"] !== expected_org + redirect_to '/unauthorized', status: 401 + else + session[:userinfo] = claims + redirect_to '/dashboard' + end + else + if claims["org_name"] && claims["org_name"].downcase !== expected_org.downcase redirect_to '/unauthorized', status: 401 else session[:userinfo] = claims redirect_to '/dashboard' end end +end ``` For more information, please read [Work with Tokens and Organizations](https://auth0.com/docs/organizations/using-tokens) on Auth0 Docs. diff --git a/lib/omniauth/auth0/jwt_validator.rb b/lib/omniauth/auth0/jwt_validator.rb index 4484734..640e5a5 100644 --- a/lib/omniauth/auth0/jwt_validator.rb +++ b/lib/omniauth/auth0/jwt_validator.rb @@ -7,6 +7,7 @@ module OmniAuth module Auth0 # JWT Validator class + # rubocop:disable Metrics/ class JWTValidator attr_accessor :issuer, :domain @@ -264,12 +265,27 @@ def verify_auth_time(id_token, leeway, max_age) end def verify_org(id_token, organization) - if organization + return unless organization + + validate_as_id = organization.start_with? 'org_' + + if validate_as_id org_id = id_token['org_id'] if !org_id || !org_id.is_a?(String) - raise OmniAuth::Auth0::TokenValidationError.new("Organization Id (org_id) claim must be a string present in the ID token") + raise OmniAuth::Auth0::TokenValidationError, + 'Organization Id (org_id) claim must be a string present in the ID token' elsif org_id != organization - raise OmniAuth::Auth0::TokenValidationError.new("Organization Id (org_id) claim value mismatch in the ID token; expected '#{organization}', found '#{org_id}'") + raise OmniAuth::Auth0::TokenValidationError, + "Organization Id (org_id) claim value mismatch in the ID token; expected '#{organization}', found '#{org_id}'" + end + else + org_name = id_token['org_name'] + if !org_name || !org_name.is_a?(String) + raise OmniAuth::Auth0::TokenValidationError, + 'Organization Name (org_name) claim must be a string present in the ID token' + elsif org_name.downcase != organization.downcase + raise OmniAuth::Auth0::TokenValidationError, + "Organization Name (org_name) claim value mismatch in the ID token; expected '#{organization}', found '#{org_name}'" end end end diff --git a/spec/omniauth/auth0/jwt_validator_spec.rb b/spec/omniauth/auth0/jwt_validator_spec.rb index b9ad5f4..eb9426b 100644 --- a/spec/omniauth/auth0/jwt_validator_spec.rb +++ b/spec/omniauth/auth0/jwt_validator_spec.rb @@ -476,41 +476,119 @@ expect(id_token['auth_time']).to eq(auth_time) end - it 'should fail when authorize params has organization but org_id is missing in the token' do - payload = { - iss: "https://#{domain}/", - sub: 'sub', - aud: client_id, - exp: future_timecode, - iat: past_timecode - } + context 'Organization claim validation' do + it 'should fail when authorize params has organization but org_id is missing in the token' do + payload = { + iss: "https://#{domain}/", + sub: 'sub', + aud: client_id, + exp: future_timecode, + iat: past_timecode + } - token = make_hs256_token(payload) - expect do - jwt_validator.verify(token, { organization: 'Test Org' }) - end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({ - message: "Organization Id (org_id) claim must be a string present in the ID token" - })) - end + token = make_hs256_token(payload) + expect do + jwt_validator.verify(token, { organization: 'org_123' }) + end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({ + message: "Organization Id (org_id) claim must be a string present in the ID token" + })) + end - it 'should fail when authorize params has organization but token org_id does not match' do - payload = { - iss: "https://#{domain}/", - sub: 'sub', - aud: client_id, - exp: future_timecode, - iat: past_timecode, - org_id: 'Wrong Org' - } + it 'should fail when authorize params has organization but org_name is missing in the token' do + payload = { + iss: "https://#{domain}/", + sub: 'sub', + aud: client_id, + exp: future_timecode, + iat: past_timecode + } - token = make_hs256_token(payload) - expect do - jwt_validator.verify(token, { organization: 'Test Org' }) - end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({ - message: "Organization Id (org_id) claim value mismatch in the ID token; expected 'Test Org', found 'Wrong Org'" - })) - end + token = make_hs256_token(payload) + expect do + jwt_validator.verify(token, { organization: 'my-organization' }) + end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and(having_attributes({ + message: 'Organization Name (org_name) claim must be a string present in the ID token' + }))) + end + it 'should fail when authorize params has organization but token org_id does not match' do + payload = { + iss: "https://#{domain}/", + sub: 'sub', + aud: client_id, + exp: future_timecode, + iat: past_timecode, + org_id: 'org_5678' + } + + token = make_hs256_token(payload) + expect do + jwt_validator.verify(token, { organization: 'org_1234' }) + end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and(having_attributes({ + message: "Organization Id (org_id) claim value mismatch in the ID token; expected 'org_1234', found 'org_5678'" + }))) + end + + it 'should fail when authorize params has organization but token org_name does not match' do + payload = { + iss: "https://#{domain}/", + sub: 'sub', + aud: client_id, + exp: future_timecode, + iat: past_timecode, + org_name: 'another-organization' + } + + token = make_hs256_token(payload) + expect do + jwt_validator.verify(token, { organization: 'my-organization' }) + end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and(having_attributes({ + message: "Organization Name (org_name) claim value mismatch in the ID token; expected 'my-organization', found 'another-organization'" + }))) + end + + it 'should not fail when correctly given an organization ID' do + payload = { + iss: "https://#{domain}/", + sub: 'sub', + aud: client_id, + exp: future_timecode, + iat: past_timecode, + org_id: 'org_1234' + } + + token = make_hs256_token(payload) + jwt_validator.verify(token, { organization: 'org_1234' }) + end + + it 'should not fail when correctly given an organization name' do + payload = { + iss: "https://#{domain}/", + sub: 'sub', + aud: client_id, + exp: future_timecode, + iat: past_timecode, + org_name: 'my-organization' + } + + token = make_hs256_token(payload) + jwt_validator.verify(token, { organization: 'my-organization' }) + end + + it 'should not fail when given an organization name in a different casing' do + payload = { + iss: "https://#{domain}/", + sub: 'sub', + aud: client_id, + exp: future_timecode, + iat: past_timecode, + org_name: 'MY-ORGANIZATION' + } + + token = make_hs256_token(payload) + jwt_validator.verify(token, { organization: 'my-organization' }) + end + end it 'should fail for RS256 token when kid is incorrect' do domain = 'example.org' sub = 'abc123'