Skip to content

Commit

Permalink
Authorization Flow Overhaul
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
bellebaum authored Dec 9, 2021
1 parent 24b9e8a commit 5e82086
Show file tree
Hide file tree
Showing 14 changed files with 408 additions and 356 deletions.
6 changes: 2 additions & 4 deletions lib/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 17 additions & 17 deletions lib/db_plugins/user_db_plugin_ldap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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}"
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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|
Expand Down
16 changes: 8 additions & 8 deletions lib/db_plugins/user_db_plugin_sqlite.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/db_plugins/user_db_plugin_yaml.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
72 changes: 22 additions & 50 deletions lib/oauth_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions lib/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
52 changes: 23 additions & 29 deletions lib/token_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
Loading

0 comments on commit 5e82086

Please sign in to comment.