From 5e82086090057d1df57f011bf1d36d5d30ae1ac8 Mon Sep 17 00:00:00 2001 From: Thomas Bellebaum <91870704+bellebaum@users.noreply.github.com> Date: Thu, 9 Dec 2021 14:57:38 +0100 Subject: [PATCH] Authorization Flow Overhaul * Added a list of authorization tasks to the user's session This allows to dynamically add tasks the user must perform before an authorization_code is issued * Replaced sucessive [] operators by dig() * Accept `prompt` and `max_age` parameters * Include `auth_time` in id_token * Adjusted a few error codes * Support for plain OAuth Authorization Flows * Better Requested Claims Handling * Remove dead code * More intuitive code for scope granting * Allow to request claims for the userinfo endpoint This introduces a new claim `omejdn_reserved` in the access token. It stores information for later retrieval, e.g. requested claims to be returned from `/userinfo` * Fixed a bug regarding the redirect_uri * Fix Tests * Fix spec reference * Only include the omejdn_reserved key when it contains actual data * Fixed switched expected/actual values in OAuth2 tests * Added tests for `/userinfo` endpoint --- lib/config.rb | 6 +- lib/db_plugins/user_db_plugin_ldap.rb | 34 +-- lib/db_plugins/user_db_plugin_sqlite.rb | 16 +- lib/db_plugins/user_db_plugin_yaml.rb | 4 +- lib/oauth_helper.rb | 72 ++--- lib/server.rb | 4 +- lib/token_helper.rb | 52 ++-- lib/user.rb | 35 +-- omejdn.rb | 344 +++++++++++++++--------- tests/config_testsetup.rb | 2 +- tests/test_admin_api.rb | 4 +- tests/test_oauth2.rb | 183 +++++++------ tests/test_selfservice_api.rb | 6 +- views/authorization_page.haml | 2 +- 14 files changed, 408 insertions(+), 356 deletions(-) diff --git a/lib/config.rb b/lib/config.rb index 5bd6ee5..396dd35 100644 --- a/lib/config.rb +++ b/lib/config.rb @@ -38,10 +38,8 @@ def self.base_config def self.base_config=(config) # Make sure those are integers - config['token']['expiration'] = config['token']['expiration'].to_i - if config['id_token'] && config['id_token']['expiration'] - config['id_token']['expiration'] = - config['id_token']['expiration'].to_i + %w[token id_token].map { |t| config[t] }.compact.each do |c| + c['expiration'] = c['expiration'].to_i end write_config OMEJDN_BASE_CONFIG_FILE, config.to_yaml end diff --git a/lib/db_plugins/user_db_plugin_ldap.rb b/lib/db_plugins/user_db_plugin_ldap.rb index f0ad9f2..0a1f587 100644 --- a/lib/db_plugins/user_db_plugin_ldap.rb +++ b/lib/db_plugins/user_db_plugin_ldap.rb @@ -68,7 +68,7 @@ def map_oidc_claim(user, key, value) def ldap_entry_to_user(entry) user_backend_config = Config.user_backend_config user = User.new - user.username = entry[user_backend_config['ldap']['uidKey']][0] + user.username = entry.dig(user_backend_config.dig('ldap', 'uidKey'), 0) user.extern = true user.password = nil user.backend = 'ldap' @@ -83,9 +83,9 @@ def ldap_entry_to_user(entry) def load_users user_backend_config = Config.user_backend_config ldap = Net::LDAP.new - ldap.host = user_backend_config['ldap']['host'] - ldap.port = user_backend_config['ldap']['port'] - base_dn = user_backend_config['ldap']['baseDN'] + ldap.host = user_backend_config.dig('ldap', 'host') + ldap.port = user_backend_config.dig('ldap', 'port') + base_dn = user_backend_config.dig('ldap', 'baseDN') t_users = [] ldap.search(base: base_dn) do |entry| puts "DN: #{entry.dn}" @@ -110,12 +110,12 @@ def users def bind(config, bdn, password) ldap = Net::LDAP.new - ldap.host = config['ldap']['host'] - ldap.port = config['ldap']['port'] + ldap.host = config.dig('ldap', 'host') + ldap.port = config.dig('ldap', 'port') puts "Trying bind for #{bdn}" ldap = Net::LDAP.new({ - host: config['ldap']['host'], - port: config['ldap']['port'], + host: config.dig('ldap', 'host'), + port: config.dig('ldap', 'port'), auth: { method: :simple, username: bdn, @@ -133,9 +133,9 @@ def bind(config, bdn, password) def nobind(config) Net::LDAP.new({ - host: config['ldap']['host'], - port: config['ldap']['port'], - base: config['ldap']['base_dn'], + host: config.dig('ldap', 'host'), + port: config.dig('ldap', 'port'), + base: config.dig('ldap', 'baseDN'), verbose: true, encryption: { method: :simple_tls, @@ -147,8 +147,8 @@ def nobind(config) def lookup_user(user, config) return @dn_cache[user.username] unless @dnCache[user.username].nil? - connect(config).search(base: config['ldap']['baseDN'], - filter: Net::LDAP::Filter.eq(config['ldap']['uidKey'], + connect(config).search(base: config.dig('ldap', 'baseDN'), + filter: Net::LDAP::Filter.eq(config.dig('ldap', 'uidKey'), user.username)) do |entry| return entry.dn end @@ -164,8 +164,8 @@ def verify_credential(user, password) puts "Trying bind for #{user_dn}" Net::LDAP.new({ - host: user_backend_config['ldap']['host'], - port: user_backend_config['ldap']['port'], + host: user_backend_config.dig('ldap', 'host'), + port: user_backend_config.dig('ldap', 'port'), auth: { method: :simple, username: user_dn, @@ -190,8 +190,8 @@ def find_by_id(username) user_backend_config = Config.user_backend_config ldap = connect(user_backend_config) p ldap - base_dn = user_backend_config['ldap']['baseDN'] - uid_key = user_backend_config['ldap']['uidKey'] + base_dn = user_backend_config.dig('ldap', 'baseDN') + uid_key = user_backend_config.dig('ldap', 'uidKey') puts "Looking for #{uid_key}=#{username}" filter = Net::LDAP::Filter.eq(uid_key, username) ldap.search(verbose: true, base: base_dn, filter: filter) do |entry| diff --git a/lib/db_plugins/user_db_plugin_sqlite.rb b/lib/db_plugins/user_db_plugin_sqlite.rb index b32dda6..3b0ae51 100644 --- a/lib/db_plugins/user_db_plugin_sqlite.rb +++ b/lib/db_plugins/user_db_plugin_sqlite.rb @@ -6,7 +6,7 @@ class SqliteUserDb < UserDb def create_user(user) user_backend_config = Config.user_backend_config - db = SQLite3::Database.open user_backend_config['sqlite']['location'] + db = SQLite3::Database.open user_backend_config.dig('sqlite', 'location') db.execute 'CREATE TABLE IF NOT EXISTS password(username TEXT PRIMARY KEY, password TEXT)' db.execute 'CREATE TABLE IF NOT EXISTS attributes(username TEXT, key TEXT, value TEXT, PRIMARY KEY (username, key))' db.execute 'INSERT INTO password(username, password) VALUES(?, ?)', user.username, user.password @@ -19,8 +19,8 @@ def create_user(user) def delete_user(username) user_backend_config = Config.user_backend_config - db = SQLite3::Database.open user_backend_config['sqlite']['location'] - user_in_sqlite = (db.execute 'SELECT EXISTS(SELECT 1 FROM password WHERE username=?)', username)[0][0] + db = SQLite3::Database.open user_backend_config.dig('sqlite', 'location') + user_in_sqlite = (db.execute 'SELECT EXISTS(SELECT 1 FROM password WHERE username=?)', username).dig(0, 0) return false unless user_in_sqlite == 1 db.execute 'DELETE FROM password WHERE username=?', username @@ -43,8 +43,8 @@ def delete_missing_attributes(user, db) def update_user(user) user_backend_config = Config.user_backend_config - db = SQLite3::Database.open user_backend_config['sqlite']['location'] - user_in_sqlite = (db.execute 'SELECT EXISTS(SELECT 1 FROM password WHERE username=?)', user.username)[0][0] + db = SQLite3::Database.open user_backend_config.dig('sqlite', 'location') + user_in_sqlite = (db.execute 'SELECT EXISTS(SELECT 1 FROM password WHERE username=?)', user.username).dig(0, 0) return false unless user_in_sqlite == 1 db.results_as_hash = true @@ -63,7 +63,7 @@ def verify_credential(user, password) def load_users user_backend_config = Config.user_backend_config - db = SQLite3::Database.open user_backend_config['sqlite']['location'] + db = SQLite3::Database.open user_backend_config.dig('sqlite', 'location') db.results_as_hash = true begin t_users = db.execute 'SELECT * FROM password' @@ -94,8 +94,8 @@ def users def change_password(user, password) user_backend_config = Config.user_backend_config - db = SQLite3::Database.open user_backend_config['sqlite']['location'] - user_in_sqlite = (db.execute 'SELECT EXISTS(SELECT 1 FROM password WHERE username=?)', user.username)[0][0] + db = SQLite3::Database.open user_backend_config.dig('sqlite', 'location') + user_in_sqlite = (db.execute 'SELECT EXISTS(SELECT 1 FROM password WHERE username=?)', user.username).dig(0, 0) return false unless user_in_sqlite == 1 db.execute 'UPDATE password SET password=? WHERE username=?', password, user.username diff --git a/lib/db_plugins/user_db_plugin_yaml.rb b/lib/db_plugins/user_db_plugin_yaml.rb index e90ceda..dcf28f4 100644 --- a/lib/db_plugins/user_db_plugin_yaml.rb +++ b/lib/db_plugins/user_db_plugin_yaml.rb @@ -39,7 +39,7 @@ def update_user(user) def load_users user_backend_config = Config.user_backend_config - (YAML.safe_load File.read user_backend_config['yaml']['location']) || [] + (YAML.safe_load File.read user_backend_config.dig('yaml', 'location')) || [] end def write_user_db(users) @@ -48,7 +48,7 @@ def write_user_db(users) users_yaml << user.to_dict end user_backend_config = Config.user_backend_config - Config.write_config(user_backend_config['yaml']['location'], users_yaml.to_yaml) + Config.write_config(user_backend_config.dig('yaml', 'location'), users_yaml.to_yaml) end def users diff --git a/lib/oauth_helper.rb b/lib/oauth_helper.rb index 44c1473..ea086d4 100644 --- a/lib/oauth_helper.rb +++ b/lib/oauth_helper.rb @@ -24,47 +24,26 @@ def self.token_response(access_token, scopes, id_token) response = {} response['access_token'] = access_token response['id_token'] = id_token unless id_token.nil? - response['expires_in'] = Config.base_config['token']['expiration'] + response['expires_in'] = Config.base_config.dig('token', 'expiration') response['token_type'] = 'bearer' response['scope'] = scopes.join ' ' JSON.generate response end - def self.supported_scopes - scopes = Set[] - Config.client_config.each do |_client_id, arr| - next if arr['scopes'].nil? - - arr['scopes'].each do |scope| - scopes.add(scope) - end - end - scopes.to_a - end - - def self.userinfo(user, token) - userinfo = {} + def self.userinfo(client, user, token) + req_claims = token.dig('omejdn_reserved', 'userinfo_req_claims') + userinfo = TokenHelper.map_claims_to_userinfo(user.attributes, req_claims, client, token['scope'].split) userinfo['sub'] = user.username - user.attributes.each do |attribute| - token[0].each do |key, _| - next unless attribute['key'] == key - - TokenHelper.add_jwt_claim(userinfo, key, attribute['value']) - end - end userinfo end - def self.default_scopes - scopes = [] - Config.scope_mapping_config.each do |mapping| - scopes << mapping[0] - end - scopes + def self.supported_scopes + Config.scope_mapping_config.map { |m| m[0] } end def self.error_response(error, desc = '') response = { 'error' => error, 'error_description' => desc } + p response JSON.generate response end @@ -106,40 +85,33 @@ def self.generate_jwks def self.openid_configuration(host, path) base_config = Config.base_config metadata = {} - metadata['issuer'] = base_config['token']['issuer'] + metadata['issuer'] = base_config.dig('token', 'issuer') metadata['authorization_endpoint'] = "#{path}/authorize" metadata['token_endpoint'] = "#{path}/token" metadata['userinfo_endpoint'] = "#{path}/userinfo" metadata['jwks_uri'] = "#{host}/.well-known/jwks.json" # metadata["registration_endpoint"] = "#{host}/FIXME" - metadata['scopes_supported'] = OAuthHelper.default_scopes + metadata['scopes_supported'] = OAuthHelper.supported_scopes metadata['response_types_supported'] = ['code'] metadata['response_modes_supported'] = ['query'] # FIXME: we only do query atm no fragment metadata['grant_types_supported'] = ['authorization_code'] - metadata['id_token_signing_alg_values_supported'] = base_config['token']['algorithm'] + metadata['id_token_signing_alg_values_supported'] = base_config.dig('token', 'algorithm') metadata end - def self.verify_authorization_request(params) - client = Client.find_by_id params['client_id'] - unless params[:response_type] == 'code' - raise OAuthHelper.error_response 'unsupported_response_type', - "Given: #{params[:response_type]}" + def self.adapt_requested_claims(req_claims) + # https://tools.ietf.org/id/draft-spencer-oauth-claims-00.html#rfc.section.3 + known_sinks = %w[access_token id_token userinfo] + default_sinks = ['access_token'] + known_sinks.each do |sink| + req_claims[sink] ||= {} + req_claims[sink].merge!(req_claims['*'] || {}) end - raise OAuthHelper.error_response 'invalid_client', '' if client.nil? - unless client.redirect_uri == - CGI.unescape(params[:redirect_uri].gsub('%20', '+')) - raise OAuthHelper.error_response 'invalid_redirect_uri', '' + default_sinks.each do |sink| + req_claims[sink].merge!(req_claims['?'] || {}) end - end - - def self.handle_authorization_request(params) - verify_authorization_request(params) - - # Seems to be in order - haml :authorization_page, locals: { - client: client, - scopes: params[:scope] - } + req_claims.delete('*') + req_claims.delete('?') + req_claims end end diff --git a/lib/server.rb b/lib/server.rb index 6232fd6..c285717 100644 --- a/lib/server.rb +++ b/lib/server.rb @@ -55,10 +55,10 @@ def self.load_pkey(token_type = 'token') end def self.load_skey(token_type = 'token') - filename = Config.base_config[token_type]['signing_key'] + filename = Config.base_config.dig(token_type, 'signing_key') setup_skey(filename) unless File.exist? filename sk = OpenSSL::PKey::RSA.new File.read(filename) - pk = load_pkey(token_type).select { |c| c.dig('certs', 0) && (c['certs'][0].check_private_key sk) }.first + pk = load_pkey(token_type).select { |c| c.dig('certs', 0) && (c.dig('certs', 0).check_private_key sk) }.first (pk || {}).merge({ 'sk' => sk, 'pk' => sk.public_key }) end end diff --git a/lib/token_helper.rb b/lib/token_helper.rb index 65fd190..a49205f 100644 --- a/lib/token_helper.rb +++ b/lib/token_helper.rb @@ -15,25 +15,27 @@ class OAuth2Error < RuntimeError # A helper for building JWT access tokens and ID tokens class TokenHelper - def server_key; end - def self.build_access_token_stub(attrs, client, scopes, resources, claims) base_config = Config.base_config now = Time.new.to_i - { + token = { 'scope' => (scopes.join ' '), 'aud' => resources, - 'iss' => base_config['token']['issuer'], + 'iss' => base_config.dig('token', 'issuer'), 'nbf' => now, 'iat' => now, 'jti' => Base64.urlsafe_encode64(rand(2**64).to_s), - 'exp' => now + base_config['token']['expiration'], + 'exp' => now + base_config.dig('token', 'expiration'), 'client_id' => client.client_id - }.merge(map_claims_to_userinfo(attrs, claims, client, scopes)) + } + reserved = {} + reserved['userinfo_req_claims'] = claims['userinfo'] unless (claims['userinfo'] || {}).empty? + token['omejdn_reserved'] = reserved unless reserved.empty? + token.merge(map_claims_to_userinfo(attrs, claims['access_token'], client, scopes)) end # Builds a JWT access token for client including scopes and attributes - def self.build_access_token(client, scopes, resources, user, claims) + def self.build_access_token(client, user, scopes, claims, resources) # Use user attributes if we have a user context, else use client # attributes. if user @@ -55,7 +57,7 @@ def self.address_claim?(key) def self.add_jwt_claim(jwt_body, key, value) # Address is handled differently. For reasons... if address_claim?(key) - jwt_body['address'] = {} if jwt_body['address'].nil? + jwt_body['address'] ||= {} jwt_body['address'][key] = value return end @@ -64,6 +66,7 @@ def self.add_jwt_claim(jwt_body, key, value) def self.map_claims_to_userinfo(attrs, claims, client, scopes) new_payload = {} + claims ||= {} # Add attribute if it was requested indirectly through OIDC # scope and scope is allowed for client. @@ -75,12 +78,12 @@ def self.map_claims_to_userinfo(attrs, claims, client, scopes) # Add attribute if it was specifically requested through OIDC # claims parameter. attrs.each do |attr| - next unless claims.key?(attr['key']) && !claims[attr['key']].nil? + next unless (name = claims[attr['key']]) - if attr['dynamic'] && claims[attr['key']]['value'] - add_jwt_claim(new_payload, attr['key'], claims[attr['key']]['value']) - elsif attr['dynamic'] && claims[attr['key']]['values'] - add_jwt_claim(new_payload, attr['key'], claims[attr['key']]['values'][0]) + if attr['dynamic'] && name['value'] + add_jwt_claim(new_payload, attr['key'], name['value']) + elsif attr['dynamic'] && name['values'] + add_jwt_claim(new_payload, attr['key'], name.dig('values', 0)) elsif attr['value'] add_jwt_claim(new_payload, attr['key'], attr['value']) end @@ -89,30 +92,21 @@ def self.map_claims_to_userinfo(attrs, claims, client, scopes) end # Builds a JWT ID token for client including user attributes - def self.build_id_token(client, uentry, nonce, claims, scopes) + def self.build_id_token(client, user, scopes, claims, nonce) base_config = Config.base_config now = Time.new.to_i new_payload = { 'aud' => client.client_id, - 'iss' => base_config['token']['issuer'], - 'sub' => uentry.username, + 'iss' => base_config.dig('id_token', 'issuer'), + 'sub' => user.username, 'nbf' => now, 'iat' => now, - 'exp' => now + base_config['id_token']['expiration'] - }.merge(map_claims_to_userinfo(uentry.attributes, claims, client, scopes)) + 'exp' => now + base_config.dig('id_token', 'expiration'), + 'auth_time' => user.auth_time + }.merge(map_claims_to_userinfo(user.attributes, claims['id_token'], client, scopes)) new_payload['nonce'] = nonce unless nonce.nil? - signing_material = Server.load_skey('token') + signing_material = Server.load_skey('id_token') kid = JSON::JWK.new(signing_material['pk'])[:kid] JWT.encode new_payload, signing_material['sk'], 'RS256', { typ: 'JWT', kid: kid } end - - # TODO: old, might needs to be changed - def self.subject_from_cert(cert) - Encoding.default_external = Encoding::UTF_8 - - subject = cert.subject.to_s(OpenSSL::X509::Name::ONELINE & ~ASN1_STRFLGS_ESC_MSB).delete(' ') - subject.force_encoding(Encoding::UTF_8) - end - - private :server_key end diff --git a/lib/user.rb b/lib/user.rb index 6648b84..11c984b 100644 --- a/lib/user.rb +++ b/lib/user.rb @@ -9,7 +9,7 @@ class User include BCrypt - attr_accessor :username, :password, :attributes, :extern, :backend + attr_accessor :username, :password, :attributes, :extern, :backend, :auth_time def self.verify_credential(user, pass) dbs = UserDbLoader.load_db @@ -108,31 +108,14 @@ def self.generate_extern_user(provider, json) user end - def claim?(claim) - parts = claim.split(':', 2) - searchkey = parts[0] - searchvalue = parts.length > 1 ? parts[1] : nil - attributes.each do |a| - key = a['key'] - next unless key == searchkey - - return a['value'] == searchvalue unless searchvalue.nil? - - return true - end - false - end - - def remove_claim(claim) - attributes.each do |a| - attributes.delete a if a['key'] == claim + def claim?(searchkey, searchvalue = nil) + attribute = attributes.select { |a| a['key'] == searchkey }.first + if attribute.nil? + false + elsif searchvalue.nil? + true + else + attribute['value'] == searchvalue end end - - def add_scopeclaim(claim) - attributes.push({ - 'key' => claim, - 'value' => true - }) - end end diff --git a/omejdn.rb b/omejdn.rb index df4e651..2fc2f5d 100644 --- a/omejdn.rb +++ b/omejdn.rb @@ -38,6 +38,10 @@ def my_path Config.base_config['host'] + Config.base_config['path_prefix'] end +def openid?(scopes) + Config.base_config['openid'] && (scopes.include? 'openid') +end + def adjust_config # account for environment overrides base_config = Config.base_config @@ -150,76 +154,70 @@ def self.get end end +########## TOKEN ISSUANCE ################## + # Handle token request post '/token' do client = nil scopes = [] resources = [params['resource'] || []].flatten - requested_token_claims = {} + req_claims = {} - if params[:grant_type] == 'client_credentials' + case params[:grant_type] + when 'client_credentials' if params[:client_assertion_type] != 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' - halt 400, OAuthHelper.error_response('invalid_request', 'Invalid client_assertion_type') + halt 400, OAuthHelper.error_response('unauthorized_grant', 'Invalid client_assertion_type') end jwt = params[:client_assertion] - halt 400, OAuthHelper.error_response('invalid_client', 'Assertion missing') if jwt.nil? + halt 400, OAuthHelper.error_response('invalid_grant', 'Assertion missing') if jwt.nil? client = Client.find_by_jwt jwt halt 400, OAuthHelper.error_response('invalid_client', 'Client unknown') if client.nil? scopes = client.filter_scopes(params[:scope]&.split) || [] - resources = [Config.base_config['token']['audience']] if resources.empty? + resources = [Config.base_config.dig('token', 'audience')] if resources.empty? halt 400, OAuthHelper.error_response('invalid_target', '') unless client.resources_allowed? resources - requested_token_claims = JSON.parse params[:claims] if params[:claims] + req_claims = JSON.parse(params[:claims] || '{}') - elsif (params[:grant_type] == 'authorization_code') && Config.base_config['openid'] + when 'authorization_code' code = params[:code] + halt 400, OAuthHelper.error_response('invalid_code', '') if code.nil? + cache = RequestCache.get[code] + halt 400, OAuthHelper.error_response('invalid_code', '') if cache.nil? # Only verify PKCE if given in request - unless RequestCache.get[code][:pkce].nil? + unless cache[:pkce].nil? halt 400, OAuthHelper.error_response('invalid_request', 'Code verifier missing') if params[:code_verifier].nil? - unless OAuthHelper.validate_pkce(RequestCache.get[code][:pkce], - params[:code_verifier], - RequestCache.get[code][:pkce_method]) + unless OAuthHelper.validate_pkce(cache[:pkce], params[:code_verifier], cache[:pkce_method]) halt 400, OAuthHelper.error_response('invalid_request', 'Code verifier mismatch') end end client = Client.find_by_id params[:client_id] halt 400, OAuthHelper.error_response('invalid_client', 'No client_id given') if client.nil? - halt 400, OAuthHelper.error_response('invalid_code', '') if code.nil? - halt 400, OAuthHelper.error_response('invalid_code', '') unless RequestCache.get.keys.include?(code) + if cache[:redirect_uri] && cache[:redirect_uri] != params[:redirect_uri] + halt 400, OAuthHelper.error_response('invalid_request') + end scopes = client.filter_scopes(params[:scope]&.split) - scopes = RequestCache.get[code][:scopes] || [] if scopes.empty? - halt 400, OAuthHelper.error_response('invalid_scope', '') unless scopes.reject do |s| - RequestCache.get[code][:scopes].include? s - end.empty? - resources = RequestCache.get[code][:resources] if resources.empty? - halt 400, OAuthHelper.error_response('invalid_target', '') unless resources.reject do |r| - RequestCache.get[code][:resources].include? r - end.empty? - requested_token_claims = RequestCache.get[code][:claims] || {} - requested_token_claims = JSON.parse params[:claims] if params[:claims] + scopes = cache[:scopes] || [] if scopes.empty? + halt 400, OAuthHelper.error_response('invalid_scope', '') unless (scopes - cache[:scopes]).empty? + resources = cache[:resources] if resources.empty? + halt 400, OAuthHelper.error_response('invalid_target', '') unless (resources - cache[:resources]).empty? + req_claims = cache[:claims] || {} + req_claims = JSON.parse params[:claims] if params[:claims] else halt 400, OAuthHelper.error_response('unsupported_grant_type', "Given: #{params[:grant_type]}") end headers['Content-Type'] = 'application/json' halt 400, OAuthHelper.error_response('access_denied', '') if scopes.empty? - resources << ("#{Config.base_config['host']}/userinfo") if scopes.include? 'openid' + resources << ("#{Config.base_config['host']}/userinfo") if openid?(scopes) resources << ("#{Config.base_config['host']}/api") unless scopes.select { |s| s.start_with? 'omejdn:' }.empty? - requested_token_claims['id_token'] ||= {} - requested_token_claims['access_token'] ||= {} - requested_token_claims['id_token'].merge!(requested_token_claims['*'] || {}) - requested_token_claims['access_token'].merge!(requested_token_claims['*'] || {}) + OAuthHelper.adapt_requested_claims req_claims + begin - user = nil - user = RequestCache.get[code][:user] unless RequestCache.get[code].nil? - # https://tools.ietf.org/html/draft-bertocci-oauth-access-token-jwt-00#section-2.2 - access_token = TokenHelper.build_access_token client, scopes, resources, user, - requested_token_claims['access_token'] - if scopes.include?('openid') && Config.base_config['openid'] - id_token = TokenHelper.build_id_token client, user, - RequestCache.get[code][:nonce], - requested_token_claims['id_token'], scopes - end + user = cache&.dig(:user) + nonce = cache&.dig(:nonce) + id_token = TokenHelper.build_id_token client, user, scopes, req_claims, nonce if openid?(scopes) + # RFC 9068 + access_token = TokenHelper.build_access_token client, user, scopes, req_claims, resources # Delete the authorization code as it is single use RequestCache.get.delete(code) OAuthHelper.token_response access_token, scopes, id_token @@ -228,66 +226,137 @@ def self.get end end -before '/.well-known*' do - headers['Cache-Control'] = "max-age=#{60 * 60 * 24}, must-revalidate" - headers.delete('Pragma') -end - -get '/.well-known/openid-configuration' do - headers['Content-Type'] = 'application/json' - p "Host #{Config.base_config['host']},#{my_path}" - JSON.generate OAuthHelper.openid_configuration(Config.base_config['host'], my_path) +########## AUTHORIZATION FLOW ################## + +# Defines tasks for the user before a code is issued +module AuthorizationTask + ACCOUNT_SELECT = 1 + LOGIN = 2 + CONSENT = 3 + ISSUE = 4 +end + +# Redirect to the current task. +# completed_task will be removed from the list +def next_task(completed_task = nil) + session[:tasks] ||= [] + session[:tasks].delete(completed_task) unless completed_task.nil? + task = session[:tasks].first + case task + when AuthorizationTask::ACCOUNT_SELECT + # FIXME: Provide a way to choose the current account without requiring another login + session[:tasks][0] = AuthorizationTask::LOGIN + session[:tasks].uniq! + next_task + when AuthorizationTask::LOGIN + redirect to("#{my_path}/login") + when AuthorizationTask::CONSENT + redirect to("#{my_path}/consent") + when AuthorizationTask::ISSUE + # Only issue code once + session[:tasks].delete(task) + issue_code + end + # The user has jumped into some stage without an initial /authorize call + # For now, redirect to /login + p "Undefined task: #{task}. Redirecting to /login" + redirect to("#{my_path}/login") end # Handle authorization request get '/authorize' do - halt 404 unless Config.base_config['openid'] - session[:url_params] = params - redirect to("#{my_path}/login") if session['user'].nil? - user = nil + # Required OAuth parameters unless params[:response_type] == 'code' halt 400, OAuthHelper.error_response('unsupported_response_type', "Given: #{params[:response_type]}") end - - user = UserSession.get[session['user']] - halt 400, OAuthHelper.error_response('invalid_user', '') if user.nil? - - client = Client.find_by_id params['client_id'] + client = Client.find_by_id params[:client_id] halt 400, OAuthHelper.error_response('invalid_client') if client.nil? + # We require specifying the scope + halt 400, OAuthHelper.error_response('invalid_scope') unless params[:scope] + if openid?(params[:scope].split) || [client.redirect_uri].flatten.length != 1 + escaped_redir = CGI.unescape(params[:redirect_uri] || '')&.gsub('%20', '+') + unless ([client.redirect_uri].flatten + ['localhost']).any? { |uri| escaped_redir == uri } + halt 400, OAuthHelper.error_response('invalid_request') + end + end - session[:scopes] = [] - scope_mapping = Config.scope_mapping_config + # Save parameters + session[:url_params] = params - client.filter_scopes(params[:scope].split).each do |s| - p "Checking scope #{s}" + # Tasks the user has to perform + session[:tasks] = [] - session[:scopes].push(s) if s == 'openid' + # We first define a minimum set of acceptable tasks + # Require Login + session[:tasks] << AuthorizationTask::LOGIN if session[:user].nil? + # If consent is not yet given to the client, demand it + unless (params[:scope].split - (session.dig(:consent, client.client_id) || [])).empty? + session[:tasks] << AuthorizationTask::CONSENT + end - # "key:value" scopes - if (s.include? ':') && user.claim?(s) - session[:scopes].push(s) - next + # The client may request some tasks on his own + # Strictly speaking, this is OIDC only, but there is no harm in supporting it for plain OAuth, + # since a client can at most require additional actions + params[:prompt]&.split&.each do |task| + case task + when 'none' + if session[:tasks].include AuthorizationTask::ACCOUNT_SELECT + halt 400, OAuthHelper.error_response('account_selection_required') + elsif session[:tasks].include AuthorizationTask::LOGIN + halt 400, OAuthHelper.error_response('login_required') + elsif session[:tasks].include AuthorizationTask::CONSENT + halt 400, OAuthHelper.error_response('consent_required') + elsif params[:prompt] != 'none' + halt 400, OAuthHelper.error_response('invalid_request', "Invalid 'prompt' values: #{params[:prompt]}") + end + when 'login' + session[:tasks] << AuthorizationTask::LOGIN + when 'consent' + session[:tasks] << AuthorizationTask::CONSENT + when 'select_account' + session[:tasks] << AuthorizationTask::ACCOUNT_SELECT end + end + if params[:max_age] && session[:user] && + (Time.new.to_i - UserSession.get[session[:user]].auth_time) > params[:max_age] + session[:tasks] << AuthorizationTask::LOGIN + end + + # Redirect the user to start the authentication flow + session[:tasks] << AuthorizationTask::ISSUE + session[:tasks].sort!.uniq! + next_task +end + +get '/consent' do + if session[:user].nil? + session[:tasks].unshift AuthorizationTask::LOGIN + next_task + end - next if scope_mapping[s].nil? || (s.include? ':') + user = UserSession.get[session[:user]] + halt 400, OAuthHelper.error_response('invalid_user', '') if user.nil? - scope_mapping[s].each do |claim| - next unless user.claim?(claim) + client = Client.find_by_id session.dig(:url_params, 'client_id') + halt 400, OAuthHelper.error_response('invalid_client') if client.nil? - session[:scopes].push(s) - break + scope_mapping = Config.scope_mapping_config + session[:scopes] = client.filter_scopes(session.dig(:url_params, :scope).split) + session[:scopes].select! do |s| + p "Checking scope #{s}" + if s.start_with? 'openid' + true + elsif s.include? ':' + key, value = s.split(':', 2) + user.claim?(key, value) + else + (scope_mapping[s] || []).any? { |claim| user.claim?(claim) } end end p "Granted scopes: #{session[:scopes]}" p "The user seems to be #{user.username}" if debug - escaped_redir = CGI.unescape(params[:redirect_uri].gsub('%20', '+')) - halt 400, OAuthHelper.error_response('invalid_redirect_uri', '') unless [client.redirect_uri, - 'localhost'].any? do |uri| - escaped_redir.include? uri - end - - session[:resources] = [params['resource'] || Config.base_config['token']['audience']].flatten + session[:resources] = [session.dig(:url_params, 'resource') || Config.base_config.dig('token', 'audience')].flatten halt 400, OAuthHelper.error_response('invalid_target') unless client.resources_allowed? session[:resources] # Seems to be in order @@ -300,33 +369,38 @@ def self.get } end -post '/authorize' do - code = OAuthHelper.new_authz_code - RequestCache.get[code] = {} - RequestCache.get[code][:user] = UserSession.get[session['user']] - RequestCache.get[code][:scopes] = session[:scopes] - RequestCache.get[code][:resources] = session[:resources] - RequestCache.get[code][:nonce] = session[:url_params][:nonce] - RequestCache.get[code][:claims] = {} - RequestCache.get[code][:claims] = JSON.parse session[:url_params]['claims'] if session[:url_params].key?('claims') - unless session[:url_params][:code_challenge].nil? - unless session[:url_params][:code_challenge_method] == 'S256' +post '/consent' do + session[:consent] ||= {} + session[:consent][session.dig(:url_params, :client_id)] = session[:scopes] + next_task AuthorizationTask::CONSENT +end + +def issue_code + url_params = session[:url_params] + cache = {} + cache[:user] = UserSession.get[session[:user]] + cache[:scopes] = session[:scopes] + cache[:resources] = session[:resources] + cache[:nonce] = url_params[:nonce] + cache[:redirect_uri] = url_params[:redirect_uri] + cache[:claims] = JSON.parse session.dig(:url_params, 'claims') || '{}' + unless url_params[:code_challenge].nil? + unless url_params[:code_challenge_method] == 'S256' halt 400, OAuthHelper.error_response('invalid_request', 'Transform algorithm not supported') end - RequestCache.get[code][:pkce] = session[:url_params][:code_challenge] - RequestCache.get[code][:pkce_method] = session[:url_params][:code_challenge_method] + cache[:pkce] = url_params[:code_challenge] + cache[:pkce_method] = url_params[:code_challenge_method] end - redirect_uri = session[:url_params][:redirect_uri] - resp = "?code=#{code}&state=#{session[:url_params][:state]}" + code = OAuthHelper.new_authz_code + RequestCache.get[code] = cache + redirect_uri = url_params[:redirect_uri] + resp = "?code=#{code}&state=#{url_params[:state]}" redirect to(redirect_uri + resp) end -get '/.well-known/jwks.json' do - headers['Content-Type'] = 'application/json' - OAuthHelper.generate_jwks.to_json -end +########## USERINFO ################## before '/userinfo' do @user = nil @@ -336,9 +410,10 @@ def self.get halt 401 if jwt.nil? || jwt.empty? begin key = Server.load_skey['sk'] - @token = JWT.decode jwt, key.public_key, true, { algorithm: Config.base_config['token']['algorithm'] } - @user = User.find_by_id(@token[0]['sub']) - halt 403 unless [@token[0]['aud']].flatten.include?("#{Config.base_config['host']}/userinfo") + @token = (JWT.decode jwt, key.public_key, true, { algorithm: Config.base_config.dig('token', 'algorithm') })[0] + @client = Client.find_by_id @token['client_id'] + @user = User.find_by_id(@token['sub']) + halt 403 unless [@token['aud']].flatten.include?("#{Config.base_config['host']}/userinfo") rescue StandardError => e p e if debug @user = nil @@ -348,19 +423,19 @@ def self.get get '/userinfo' do headers['Content-Type'] = 'application/json' - JSON.generate OAuthHelper.userinfo(@user, @token) + JSON.generate OAuthHelper.userinfo(@client, @user, @token) end ########## LOGIN/LOGOUT ################## get '/logout' do - session['user'] = nil + session[:user] = nil redirect_uri = params['post_logout_redirect_uri'] || "#{my_path}/login" redirect to(redirect_uri) end post '/logout' do - session['user'] = nil + session[:user] = nil redirect_uri = params['post_logout_redirect_uri'] || "#{my_path}/login" redirect to(redirect_uri) end @@ -393,55 +468,48 @@ def self.get redirect to("#{my_path}/login?error=\"Credentials incorrect\"") unless User.verify_credential(user, params[:password]) nonce = rand(2**512) + user.auth_time = Time.new.to_i UserSession.get[nonce] = user - session['user'] = nonce - if session[:url_params].nil? - redirect to("#{my_path}/login") - else - redirect to("#{my_path}/authorize?#{URI.encode_www_form(session[:url_params]).gsub('+', '%20')}") - end + session[:user] = nonce + next_task AuthorizationTask::LOGIN end # FIXME # This should also be more generic and use the correct OP get '/oauth_cb' do - oauth_providers = Config.oauth_provider_config code = params[:code] at = nil - provider_index = 0 - oauth_providers.each do |provider| - break if provider['name'] == params[:provider] + oauth_providers = Config.oauth_provider_config + provider = oauth_providers.select { |pv| pv['name'] == params[:provider] }.first - provider_index += 1 - end - uri = URI(oauth_providers[provider_index]['token_endpoint']) + uri = URI(provider['token_endpoint']) Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| req = Net::HTTP::Post.new(uri) req.set_form_data('code' => code, - 'client_id' => oauth_providers[provider_index]['client_id'], - 'client_secret' => oauth_providers[provider_index]['client_secret'], + 'client_id' => provider['client_id'], + 'client_secret' => provider['client_secret'], 'grant_type' => 'authorization_code', - 'redirect_uri' => oauth_providers[provider_index]['redirect_uri']) + 'redirect_uri' => provider['redirect_uri']) res = http.request req at = JSON.parse(res.body)['access_token'] end return 'Unauthorized' if at.nil? user = nil - nonce = rand(2**512) - uri = URI(oauth_providers[provider_index]['userinfo_endpoint']) + uri = URI(provider['userinfo_endpoint']) Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| req = Net::HTTP::Get.new(uri) req['Authorization'] = "Bearer #{at}" res = http.request req - user = User.generate_extern_user(oauth_providers[provider_index], JSON.parse(res.body)) + user = User.generate_extern_user(provider, JSON.parse(res.body)) end return 'Internal Error' if user.username.nil? + nonce = rand(2**512) + user.auth_time = Time.new.to_i UserSession.get[nonce] = user - session['user'] = nonce - redirect to(my_path) if session[:url_params].nil? # This is actually an error - redirect to("#{my_path}/authorize?#{URI.encode_www_form(session[:url_params])}") + session[:user] = nonce + next_task AuthorizationTask::LOGIN end ########## User Selfservice ########## @@ -457,14 +525,14 @@ def self.get jwt = env.fetch('HTTP_AUTHORIZATION', '').slice(7..-1) halt 401 if jwt.nil? || jwt.empty? token = JWT.decode(jwt, Server.load_skey['sk'].public_key, true, - { algorithm: Config.base_config['token']['algorithm'] }) - halt 403 unless [token[0]['aud']].flatten.include?("#{Config.base_config['host']}/api") - @scopes = token[0]['scope'].split + { algorithm: Config.base_config.dig('token', 'algorithm') })[0] + halt 403 unless [token['aud']].flatten.include?("#{Config.base_config['host']}/api") + @scopes = token['scope'].split @user_is_admin = (@scopes.include? 'omejdn:admin') @user_may_write = (@scopes.include? 'omejdn:write') || @user_is_admin @user_may_read = (@scopes.include? 'omejdn:read') || @user_may_write - @user = User.find_by_id token[0]['sub'] - @client = Client.find_by_id token[0]['sub'] + @user = User.find_by_id token['sub'] + @client = Client.find_by_id token['client_id'] rescue StandardError => e p e if debug halt 401 @@ -747,6 +815,24 @@ def self.get halt 404 end +########## WELL-KNOWN ENDPOINTS ################## + +before '/.well-known*' do + headers['Cache-Control'] = "max-age=#{60 * 60 * 24}, must-revalidate" + headers.delete('Pragma') +end + +get '/.well-known/jwks.json' do + headers['Content-Type'] = 'application/json' + OAuthHelper.generate_jwks.to_json +end + +get '/.well-known/openid-configuration' do + headers['Content-Type'] = 'application/json' + p "Host #{Config.base_config['host']},#{my_path}" + JSON.generate OAuthHelper.openid_configuration(Config.base_config['host'], my_path) +end + get '/.well-known/webfinger' do halt 400 if params[:resource].nil? diff --git a/tests/config_testsetup.rb b/tests/config_testsetup.rb index b3b9d4d..ab05f68 100644 --- a/tests/config_testsetup.rb +++ b/tests/config_testsetup.rb @@ -55,7 +55,7 @@ def self.clients [{ 'client_id' => 'testClient', 'name' => 'omejdn admin ui', - 'allowed_scopes' => ['omejdn:write'], + 'allowed_scopes' => ['omejdn:write', 'openid', 'email'], 'redirect_uri' => 'http://localhost:4200', 'attributes' => [] }, diff --git a/tests/test_admin_api.rb b/tests/test_admin_api.rb index f49ae5d..6464ff4 100644 --- a/tests/test_admin_api.rb +++ b/tests/test_admin_api.rb @@ -17,8 +17,8 @@ def setup TestSetup.setup client = Client.find_by_id 'testClient' - @token = TokenHelper.build_access_token client, ['omejdn:admin'], TestSetup.config['host']+"/api", nil, {} - @insufficient_token = TokenHelper.build_access_token client, ['omejdn:write'], "test", nil, {} + @token = TokenHelper.build_access_token client, nil, ['omejdn:admin'], {}, TestSetup.config['host']+"/api" + @insufficient_token = TokenHelper.build_access_token client, nil, ['omejdn:write'], {}, "test" @testCertificate = File.read './tests/test_resources/testClient.pem' end diff --git a/tests/test_oauth2.rb b/tests/test_oauth2.rb index 61a6c03..81a6038 100644 --- a/tests/test_oauth2.rb +++ b/tests/test_oauth2.rb @@ -39,7 +39,7 @@ def teardown def request_client_credentials(client, alg, key, certificate, query_additions='', should_work=true) iss = client.client_id now = Time.new.to_i - payload = { aud: Config.base_config['token']['issuer'], sub: iss, iss: iss, iat: now, nbf: now, exp: now + 3600 } + payload = { aud: Config.base_config.dig('token','issuer'), sub: iss, iss: iss, iat: now, nbf: now, exp: now + 3600 } client.certificate = certificate query = 'grant_type=client_credentials'+ '&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer'+ @@ -50,9 +50,8 @@ def request_client_credentials(client, alg, key, certificate, query_additions='' JSON.parse last_response.body end - def check_keys(hash, keylist) - assert hash.keys.reject{|k| keylist.include?k}.empty? - assert keylist.reject{|k| hash.keys.include?k}.empty? + def check_keys(keylist, hash) + assert_equal keylist.sort, hash.keys.sort end def decode_jwt(jwt) @@ -60,20 +59,20 @@ def decode_jwt(jwt) assert last_response.ok? server_keys = JSON::JWK::Set.new JSON.parse(last_response.body) JWT.decode(jwt,nil,true, { - algorithms: [TestSetup.config['token']['algorithm']], + algorithms: [TestSetup.config.dig('token','algorithm')], jwks: {'keys'=>server_keys}}) end def extract_access_token(response) - check_keys response, ["access_token","expires_in","token_type","scope"] - assert_equal response["expires_in"], TestSetup.config['token']['expiration'] - assert_equal response["token_type"], "bearer" - assert_equal response["scope"], "omejdn:write" + check_keys ["access_token","expires_in","token_type","scope"], response + assert_equal TestSetup.config.dig('token','expiration'), response["expires_in"] + assert_equal "bearer", response["token_type"] + assert_equal "omejdn:write", response["scope"] jwt = decode_jwt response['access_token'] - check_keys jwt[1], ['typ','kid','alg'] - assert_equal jwt[1]['typ'], 'at+jwt' - assert_equal jwt[1]['alg'], TestSetup.config['token']['algorithm'] + check_keys ['typ','kid','alg'], jwt[1] + assert_equal 'at+jwt', jwt.dig(1,'typ') + assert_equal TestSetup.config.dig('token','algorithm'), jwt.dig(1,'alg') return jwt[0] end @@ -82,16 +81,16 @@ def test_client_credentials response = request_client_credentials @client, "ES256", @priv_key_ec256, @certificate_ec256 at = extract_access_token response - check_keys at, ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub'] - assert_equal at['scope'], 'omejdn:write' - assert_equal at['aud'], [TestSetup.config['token']['audience'], TestSetup.config['host']+'/api'] - assert_equal at['iss'], TestSetup.config['token']['issuer'] + check_keys ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub'], at + assert_equal 'omejdn:write', at['scope'] + assert_equal [TestSetup.config.dig('token','audience'), TestSetup.config['host']+'/api'], at['aud'] + assert_equal TestSetup.config.dig('token','issuer'), at['iss'] assert at['nbf'] <= Time.new.to_i - assert_equal at['iat'], at['nbf'] - assert_equal at['exp'], at['nbf']+response["expires_in"] + assert_equal at['nbf'], at['iat'] + assert_equal at['nbf']+response["expires_in"], at['exp'] assert at['jti'] - assert_equal at['client_id'], @client.client_id - assert_equal at['sub'], at['client_id'] + assert_equal @client.client_id, at['client_id'] + assert_equal at['client_id'], at['sub'] end def test_client_credentials_with_resources @@ -101,16 +100,15 @@ def test_client_credentials_with_resources response = request_client_credentials @client2, "ES256", @priv_key_ec256, @certificate_ec256, resources at = extract_access_token response - check_keys at, ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub'] - assert_equal at['scope'], 'omejdn:write' - assert_equal at['aud'], ['http://example.org', TestSetup.config['host']+'/api'] - assert_equal at['iss'], TestSetup.config['token']['issuer'] + assert_equal 'omejdn:write', at['scope'] + assert_equal ['http://example.org', TestSetup.config['host']+'/api'], at['aud'] + assert_equal TestSetup.config.dig('token','issuer'), at['iss'] assert at['nbf'] <= Time.new.to_i - assert_equal at['iat'], at['nbf'] - assert_equal at['exp'], at['nbf']+response["expires_in"] + assert_equal at['nbf'], at['iat'] + assert_equal at['nbf']+response["expires_in"], at['exp'] assert at['jti'] - assert_equal at['client_id'], @client2.client_id - assert_equal at['sub'], at['client_id'] + assert_equal @client2.client_id, at['client_id'] + assert_equal at['client_id'], at['sub'] end def test_client_credentials_scope_rejection @@ -118,16 +116,16 @@ def test_client_credentials_scope_rejection response = request_client_credentials @client, "ES256", @priv_key_ec256, @certificate_ec256, additional_scopes at = extract_access_token response - check_keys at, ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub'] - assert_equal at['scope'], 'omejdn:write' - assert_equal at['aud'], [TestSetup.config['token']['audience'], TestSetup.config['host']+'/api'] - assert_equal at['iss'], TestSetup.config['token']['issuer'] + check_keys ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub'], at + assert_equal 'omejdn:write', at['scope'] + assert_equal [TestSetup.config.dig('token','audience'), TestSetup.config['host']+'/api'], at['aud'] + assert_equal TestSetup.config.dig('token','issuer'), at['iss'] assert at['nbf'] <= Time.new.to_i - assert_equal at['iat'], at['nbf'] - assert_equal at['exp'], at['nbf']+response["expires_in"] + assert_equal at['nbf'], at['iat'] + assert_equal at['nbf']+response["expires_in"], at['exp'] assert at['jti'] - assert_equal at['client_id'], @client.client_id - assert_equal at['sub'], at['client_id'] + assert_equal @client.client_id, at['client_id'] + assert_equal at['client_id'], at['sub'] end def test_client_credentials_dynamic_claims @@ -145,17 +143,17 @@ def test_client_credentials_dynamic_claims response = request_client_credentials @client_dyn_claims, "ES256", @priv_key_ec256, @certificate_ec256, query_additions at = extract_access_token response - check_keys at, ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub', 'dynattribute'] - assert_equal at['scope'], 'omejdn:write' - assert_equal at['aud'], [TestSetup.config['token']['audience'], TestSetup.config['host']+'/api'] - assert_equal at['iss'], TestSetup.config['token']['issuer'] + check_keys ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub', 'dynattribute','omejdn_reserved'], at + assert_equal 'omejdn:write', at['scope'] + assert_equal [TestSetup.config.dig('token','audience'), TestSetup.config['host']+'/api'], at['aud'] + assert_equal TestSetup.config.dig('token','issuer'), at['iss'] assert at['nbf'] <= Time.new.to_i - assert_equal at['iat'], at['nbf'] - assert_equal at['exp'], at['nbf']+response["expires_in"] + assert_equal at['nbf'], at['iat'] + assert_equal at['nbf']+response["expires_in"], at['exp'] assert at['jti'] - assert_equal at['client_id'], @client_dyn_claims.client_id - assert_equal at['sub'], at['client_id'] - assert_equal at['dynattribute'], requested_claims['*']['dynattribute']['value'] + assert_equal @client_dyn_claims.client_id, at['client_id'] + assert_equal at['client_id'], at['sub'] + assert_equal requested_claims.dig('*','dynattribute','value'), at['dynattribute'] end def test_algorithms @@ -167,36 +165,45 @@ def test_algorithms request_client_credentials @client, "PS256", @priv_key_rsa, @certificate_rsa, '', false end - def request_authorization(user, client, query_additions='', should_work=true) - # POST /login (Separating pass and word in the hope of silencing Sonarcloud) - post ('/login?username='+user['username']+'&pass'+'word=mypass'+'word'),{},{} - good_so_far = last_response.redirect? - assert good_so_far if should_work - assert_equal "http://localhost:4567/login", last_response.original_headers['Location'] - + def request_authorization(user, client, query_additions='', should_work=true, scopes = ['omejdn:write']) # GET /authorize get ('/authorize?response_type=code'+ - '&scope=omejdn:write'+ + '&scope='+scopes.join(' ')+ '&client_id='+client.client_id+ '&redirect_uri='+client.redirect_uri+ '&state=testState'+query_additions), {}, {} # p last_response - good_so_far &= last_response.ok? + good_so_far = last_response.redirect? assert good_so_far if should_work + assert ["http://localhost:4567/consent", "http://localhost:4567/login"].include? last_response.original_headers['Location'] - # POST /authorize - post '/authorize', {}, {} + # POST /login (Separating pass and word in the hope of silencing Sonarcloud) + post ('/login?username='+user['username']+'&pass'+'word=mypass'+'word'),{},{} + good_so_far &= last_response.redirect? + assert good_so_far if should_work + assert_equal "http://localhost:4567/consent", last_response.original_headers['Location'] + + # GET /consent + get '/consent', {}, {} + good_so_far &= last_response.ok? + assert good_so_far if should_work + + # POST /consent + post '/consent', {}, {} good_so_far &= last_response.redirect? assert good_so_far if should_work + # p last_response header_hash = CGI.parse(last_response.original_headers['Location']) assert code=header_hash[client.redirect_uri+'?code'].first + # p code assert_equal 'testState', header_hash['state'].first # Get /token query = 'grant_type=authorization_code'+ '&code='+code+ '&client_id='+client.client_id+ - '&scope=omejdn:write'+query_additions + '&scope='+scopes.join(' ')+query_additions+ + '&redirect_uri='+client.redirect_uri post ('/token?'+query), {}, {} good_so_far &= last_response.ok? assert good_so_far == should_work @@ -207,16 +214,16 @@ def test_authorization_flow response = request_authorization TestSetup.users[0], @client at = extract_access_token response - check_keys at, ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub', 'omejdn'] - assert_equal at['scope'], 'omejdn:write' - assert_equal at['aud'], [TestSetup.config['token']['audience'], TestSetup.config['host']+'/api'] - assert_equal at['iss'], TestSetup.config['token']['issuer'] + check_keys ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub', 'omejdn'], at + assert_equal 'omejdn:write', at['scope'] + assert_equal [TestSetup.config.dig('token','audience'), TestSetup.config['host']+'/api'], at['aud'] + assert_equal TestSetup.config.dig('token','issuer'), at['iss'] assert at['nbf'] <= Time.new.to_i - assert_equal at['iat'], at['nbf'] - assert_equal at['exp'], at['nbf']+response["expires_in"] + assert_equal at['nbf'], at['iat'] + assert_equal at['nbf']+response["expires_in"], at['exp'] assert at['jti'] - assert_equal at['client_id'], @client.client_id - assert_equal at['sub'], TestSetup.users[0]['username'] + assert_equal @client.client_id, at['client_id'] + assert_equal TestSetup.users.dig(0,'username'), at['sub'] assert_equal 'write', at['omejdn'] end @@ -230,16 +237,16 @@ def test_authorization_flow_with_resources response = request_authorization TestSetup.users[0], @client2, resources at = extract_access_token response - check_keys at, ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub', 'omejdn'] - assert_equal at['scope'], 'omejdn:write' - assert_equal at['aud'], ['http://example.org', TestSetup.config['host']+'/api'] - assert_equal at['iss'], TestSetup.config['token']['issuer'] + check_keys ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub', 'omejdn'], at + assert_equal 'omejdn:write', at['scope'] + assert_equal ['http://example.org', TestSetup.config['host']+'/api'], at['aud'] + assert_equal TestSetup.config.dig('token','issuer'), at['iss'] assert at['nbf'] <= Time.new.to_i - assert_equal at['iat'], at['nbf'] - assert_equal at['exp'], at['nbf']+response["expires_in"] + assert_equal at['nbf'], at['iat'] + assert_equal at['nbf']+response["expires_in"], at['exp'] assert at['jti'] - assert_equal at['client_id'], @client2.client_id - assert_equal at['sub'], TestSetup.users[0]['username'] + assert_equal @client2.client_id, at['client_id'] + assert_equal TestSetup.users.dig(0,'username'), at['sub'] assert_equal 'write', at['omejdn'] end @@ -258,17 +265,29 @@ def test_authorization_flow_with_claims response = request_authorization TestSetup.users[2], @client, query_additions at = extract_access_token response - check_keys at, ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub', 'omejdn', 'dynattribute'] - assert_equal at['scope'], 'omejdn:write' - assert_equal at['aud'], [TestSetup.config['token']['audience'], TestSetup.config['host']+'/api'] - assert_equal at['iss'], TestSetup.config['token']['issuer'] + check_keys ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub', 'omejdn', 'dynattribute', 'omejdn_reserved'], at + assert_equal 'omejdn:write', at['scope'] + assert_equal [TestSetup.config.dig('token','audience'), TestSetup.config['host']+'/api'], at['aud'] + assert_equal TestSetup.config.dig('token','issuer'), at['iss'] assert at['nbf'] <= Time.new.to_i - assert_equal at['iat'], at['nbf'] - assert_equal at['exp'], at['nbf']+response["expires_in"] + assert_equal at['nbf'], at['iat'] + assert_equal at['nbf']+response["expires_in"], at['exp'] assert at['jti'] - assert_equal at['client_id'], @client.client_id - assert_equal at['sub'], TestSetup.users[2]['username'] + assert_equal @client.client_id, at['client_id'] + assert_equal TestSetup.users.dig(2,'username'), at['sub'] assert_equal 'write', at['omejdn'] - assert_equal at['dynattribute'], requested_claims['*']['dynattribute']['value'] + assert_equal requested_claims.dig('*','dynattribute','value'), at['dynattribute'] + end + + def request_userinfo(access_token) + get '/userinfo', {}, {'HTTP_AUTHORIZATION' => "Bearer #{access_token}"} + assert last_response.ok? + JSON.parse last_response.body + end + + def test_userinfo_endpoint + response = request_authorization TestSetup.users[0], @client, '', true, ['openid','email'] + userinfo = request_userinfo response['access_token'] + check_keys ['openid','email','sub'], userinfo end end diff --git a/tests/test_selfservice_api.rb b/tests/test_selfservice_api.rb index e5fbc90..d4c5717 100644 --- a/tests/test_selfservice_api.rb +++ b/tests/test_selfservice_api.rb @@ -17,9 +17,9 @@ def setup TestSetup.setup user = User.find_by_id 'testUser' client = Client.find_by_id 'testClient' - @write_token = TokenHelper.build_access_token client, ['omejdn:write'], TestSetup.config['host']+"/api", user, {} - @read_token = TokenHelper.build_access_token client, ['omejdn:read'], TestSetup.config['host']+"/api", user, {} - @useless_token = TokenHelper.build_access_token client, [], TestSetup.config['host']+"/api", user, {} + @write_token = TokenHelper.build_access_token client, user, ['omejdn:write'], {}, TestSetup.config['host']+"/api" + @read_token = TokenHelper.build_access_token client, user, ['omejdn:read'], {}, TestSetup.config['host']+"/api" + @useless_token = TokenHelper.build_access_token client, user, [], {}, TestSetup.config['host']+"/api" end def teardown diff --git a/views/authorization_page.haml b/views/authorization_page.haml index 69c3710..a65bf6e 100644 --- a/views/authorization_page.haml +++ b/views/authorization_page.haml @@ -7,7 +7,7 @@ %span Currently logged in as %b #{locals[:user].username} %a{:href => "#{locals[:host]}/logout", :class => "button"} Change... -%form{:action => "#{locals[:host]}/authorize", :method => "post"} +%form{:action => "#{locals[:host]}/consent", :method => "post"} %fieldset %br %h3 Requested scopes: