diff --git a/.rubocop.yml b/.rubocop.yml index b73ed29..68f1a10 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,7 +1,7 @@ AllCops: NewCops: enable Exclude: - - tests/*.rb + - tests/**/*.rb - vendor/**/* TargetRubyVersion: 2.5 Metrics/BlockLength: diff --git a/Gemfile b/Gemfile index 7670f51..f0b507c 100644 --- a/Gemfile +++ b/Gemfile @@ -16,7 +16,8 @@ end # Necessary Gems for Plugins group :plugins do gem 'net-ldap' - gem 'sqlite3' + gem 'pg' + gem 'rqrcode' end # Development only diff --git a/Gemfile.lock b/Gemfile.lock index 614ac52..ccc8ad8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ GEM specs: ast (2.4.2) bcrypt (3.1.18) + chunky_png (1.4.0) daemons (1.4.1) eventmachine (1.2.7) haml (5.2.2) @@ -17,6 +18,7 @@ GEM parallel (1.22.1) parser (3.1.2.0) ast (~> 2.4.1) + pg (1.3.5) power_assert (2.0.1) rack (2.2.3.1) rack-protection (2.2.0) @@ -26,6 +28,10 @@ GEM rainbow (3.1.1) regexp_parser (2.5.0) rexml (3.2.5) + rqrcode (2.1.1) + chunky_png (~> 1.0) + rqrcode_core (~> 1.0) + rqrcode_core (1.2.0) rubocop (1.30.0) parallel (~> 1.10) parser (>= 3.1.0.0) @@ -50,7 +56,6 @@ GEM rack-protection (= 2.2.0) sinatra (= 2.2.0) tilt (~> 2.0) - sqlite3 (1.4.2) temple (0.8.2) test-unit (3.5.3) power_assert @@ -70,11 +75,12 @@ DEPENDENCIES jwt net-ldap openssl + pg rack-test + rqrcode rubocop sinatra sinatra-contrib - sqlite3 test-unit thin diff --git a/config/clients.yml b/config/clients.yml deleted file mode 100644 index ad8364d..0000000 --- a/config/clients.yml +++ /dev/null @@ -1,16 +0,0 @@ ---- -- client_id: exampleClient - client_name: An example Client - grant_types: authorization_code - client_uri: http://localhost:4200 - logo_uri: http://localhost:4200/assets/img/fhg.jpg - tos_uri: http://localhost:4200 - policy_uri: http://localhost:4200 - software_id: MyAwesomeClient - software_version: 1.0.0 - token_endpoint_auth_method: none - redirect_uris: http://localhost:4200 - post_logout_redirect_uris: http://localhost:4200 - scope: - - openid - - omejdn:admin \ No newline at end of file diff --git a/config/oauth_providers.yml b/config/oauth_providers.yml deleted file mode 100644 index 3642be7..0000000 --- a/config/oauth_providers.yml +++ /dev/null @@ -1,14 +0,0 @@ -## You may configure additional OAuth Providers as follows -#- name: 'Some OAuth Provider' -# redirect_uri: 'http://localhost:4567/oauth_cb?provider=BFH' -# client_id: 'our_client_id' -# client_secret: 'our_secret' -# scopes: -# - 'email' -# - 'profile' -# - 'openid' -# external_userid: 'nickname' -# authorization_endpoint: 'https://authorize' -# token_endpoint: 'https://token' -# userinfo_endpoint: 'https://userinfo' -# response_type: 'code' diff --git a/config/omejdn.yml b/config/omejdn.yml deleted file mode 100644 index dde7522..0000000 --- a/config/omejdn.yml +++ /dev/null @@ -1,76 +0,0 @@ ---- -## Welcome to Omejdn. -## -## Please have a look at the documentation (/docs) first if you have any questions. -## This is the main configuration file. -## Once you start Omejdn for the first time, the comments here will disappear -## and all non-specified values will be filled in with their default values. -## Omejdn is aware of any changes to this file and will always use the new configuration. - -## Omejdn's Issuer Identifier. -## Please ensure that, assuming the issuer id is https://example.org/some/path, -## Omejdn's /.well-known/oauth-authorization-server endpoint is reachable as -## https://example.org/.well-known/oauth-authorization-server/some/path (per RFC 8414). -## To support dynamic OpenID clients, the same endpoint should be available as -## https://example.org/.well-known/openid-configuration/some/path and -## https://example.org/some/path/.well-known/openid-configuration for backwards compatibility. -#issuer: https://localhost:4567 - -## The URL where Omejdn's endpoints are mounted, in case it differs from `issuer` -#front_url: https://localhost:4567 - -## IP and (optionally) port to bind to -## Changes only apply after a restart -#bind_to: 0.0.0.0:4567 - -## Application Environment. Set to production to supress debug output -#environment: development - -## Enable OpenID functionality (requires at least one user_db plugin) -#openid: false - -## The default user_db plugin to use -#user_backend_default: 'yaml' - -## Default `aud` value in tokens -#default_audience: '' - -## Accept different values as `aud` -#accept_audience: https://localhost:4567 - -## Set expiration time and algorithm for each token -## Does not affect already issued tokens -#access_token: -# expiration: 3600 -# algorithm: RS256 -#id_token: -# expiration: 3600 -# algorithm: RS256 - -## Plugins enable additional functionality. -## See the respective plugin for configuration options. -## Loading and unloading of plugins requires a restart -#plugins: -# user_db: -# yaml: -# location: config/users.yml -# sqlite: -# location: config/users.db -# ldap: -# host: localhost -# port: 636 -# base_dn: '' -# uid_key: dn -# api: -# admin_v1: -# user_selfservice_v1: -# allow_deletion: true -# allow_password_change: true -# editable_attributes: -# - email -# - address -# - phone_number -# claim_mapper: -# attribute: -# skip_access_token: false -# skip_id_token: false diff --git a/config/scope_description.yml b/config/scope_description.yml deleted file mode 100644 index a1bf3ce..0000000 --- a/config/scope_description.yml +++ /dev/null @@ -1,9 +0,0 @@ -# Human readable descriptions for each scope. -# These are shown to a user before authorizing a client -profile: "Standard profile claims (e.g.: Name, picture, website, gender, birthdate, location)" -email: "Email-Address" -address: "Address" -phone: "Phone-number" -omejdn:read: "Read access to the Omejdn server API" -omejdn:write: "Write access to the Omejdn server API" -omejdn:admin: "Access to the Omejdn server admin API" diff --git a/config/scope_mapping.yml b/config/scope_mapping.yml deleted file mode 100644 index e396da9..0000000 --- a/config/scope_mapping.yml +++ /dev/null @@ -1,44 +0,0 @@ -# A mapping from scopes to claims -# Clients and users requesting these scopes will have these -# included in the resulting access token, -# if they have attributes corresponding to the claim names -# Scopes cannot be requested by clients for users unless they have -# at least one of the claims as attribute - -# ----- Omejdn API scopes ----- -omejdn:read: - - omejdn -omejdn:write: - - omejdn -omejdn:admin: - - omejdn - -# ----- OpenID Related Scopes ----- -profile: - - name - - family_name - - given_name - - middle_name - - nickname - - preferred_username - - profile - - picture - - website - - gender - - birthdate - - zoneinfo - - locale - - updated_at -email: - - email - - email_verified -address: - - formatted - - street_address - - locality - - region - - postal_code - - country -phone: - - phone_number - - phone_number_verified diff --git a/config/webfinger.yml b/config/webfinger.yml deleted file mode 100644 index 1121301..0000000 --- a/config/webfinger.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -# Define a webfinger here -#localhost.com: -# name: test -# website: http://localhost:4567 diff --git a/docs/Plugins/Events.md b/docs/Plugins/Events.md new file mode 100644 index 0000000..7617af4 --- /dev/null +++ b/docs/Plugins/Events.md @@ -0,0 +1,322 @@ +# Events + +These are the events that are emitted by Omejdn. +You may hook into any one of them by using + +```ruby +def event_handler(bind) + # Edit the binding context + # E.g. using bind.local_variable_get(:var) + # or bind.local_variable_set(:var) +end + +PluginLoader.register('', :event_handler) +``` + +The event_handler will be called every time Omejdn comes across + +```ruby +PluginLoader.fire('', binding) +``` + +This allows you to directly edit any values in the caller's context. + +Omejdn has two types of events. +State events allow you to hook into Omejdn whenever state is to be persisted, +while Flow events allow you to directly hook into the authorization flows. + +## State Events + +Omejdn maintains as little persistent state as possible. +Ignoring session data and caches, which are reset with each startup, +the following diagram illustrates how data is saved by Omejdn. + +Each component (in red) sends out Events to store, update, or load data. +These are then acted upon by plugins. +Default plugins are included in Omejdn's Core code +(usually in the same file as the component itself). + +![Wiring diagram for State in Omejdn](omejdn_persistent_state.svg) + +The default implementations for Keys +(keys and any other cryptographic material such as certificates) +and Config (all other configuration data) +persist state by writing it to disk in an easy to handle format. +The components are neither designed to be efficient, +nor to support large numbers of clients and users. +They are designed for data that does not change often, +such as the server configuration. + +For this reason, if you have a larger deployment, +you might also want to re-implement the Client and User plugins, +whose default implementations are just delegating their data to dedicated configuration sections. + +### Configuration Events + +Configuration data is stored in `section`s, which can be read and overwritten as a whole. +Each `section`'s content can be given as either a hash or an array. +The default implementation saves each section as YAML in `/config/
.yml`. + +- CONFIGURATION_STORE + +Should overwrite a section identified by **section** with **data**. + +- CONFIGURATION_LOAD + +Should return the content of section **section**, or **fallback** if no data is stored yet. + +### Cryptographic Material Events + +Keys and certificates are stored with two identifiers. +A `target_type` gives the type of entity holding the key or certificate (e.g. `omejdn`), +while the `target` specifies an identifier for that specific key or certificate (or both). +Omejdn handles key material as hashes with the keys `sk` and `pk` for the secret and public key, +`kid` for a key id, and `certs` for an array of certificates representing a certificate chain. +The default implementation saves secret keys and certificates as PEM in `/keys//.{pem,cert}`. + +- KEYS_STORE + +Should store the key material **key_material** for the given **target_type** and **target**. + +- KEYS_LOAD + +Should return the key material for the given **target_type** and **target**. +If **create_key** is `true` and no key stored yet, a new key should be created and stored. + +- KEYS_LOAD_ALL + +Should return an array of all key material for the given **target_type**. + +### Client Events + +Clients are objects in Omejdn identified by their `client_id`, which is part of their `metadata`. +They also have an attribute `backend`. +Plugins implementing these events should set the backend of returned clients to an identifier for the plugin, +and drop any events presenting them clients from other plugins, returning `nil`. +The default implementation stores clients in a dedicated config section +and defines a `target_type` for clients to store keys. + +- CLIENT_GET + +Should return the client identified by `client_id`, or `nil` if not found. + +- CLIENT_GET_ALL + +Should return an array of all stored clients. + +- CLIENT_CREATE + +Should store the client **client**, if the **client_backend** is correct. + +- CLIENT_UPDATE + +Should replace that client with **client**, +which has the same `client_id`, if it is stored by this plugin. + +- CLIENT_DELETE + +Should delete the client identified by **client_id**, if stored by this plugin. + +#### Client Authentication Events + +- CLIENT_AUTHENTICATION_CERTIFICATE_GET + +Should return the certificate of **client**. + +- CLIENT_AUTHENTICATION_CERTIFICATE_UPDATE + +Should replace the certificate of **client** with **new_cert**. +A value of `nil` indicates that the certificate should be deleted. + +### User Events + +Users are objects in Omejdn identified by their `username`. +They also have an attribute `backend`. +Plugins implementing these events should set the backend of returned users to an identifier for the plugin, +and drop any events presenting them users from other plugins, returning `nil`. +The default implementation stores clients in a dedicated config section. + +- USER_GET + +Should return the user identified by **username**. + +- USER_GET_ALL + +Should return an array of all stored users. + +- USER_CREATE + +Should store the user **user**, if the **user_backend** is correct. + +- USER_UPDATE + +Should replace that user with **user**, +which has the same **username**, if it is stored by this plugin. + +- USER_DELETE + +Should delete the user identified by **username**, if stored by this plugin. + +#### User Authentication Events + +N.B. Avoid storing user passwords as plain text. +A function `User.string_to_pass_hash(password)` is provided to hash passwords. +Passwords can be verified against hashes using `==`. + +- USER_AUTHENTICATION_PASSWORD_CHANGE + +Should replace the password of **user** with **password**. + +- USER_AUTHENTICATION_PASSWORD_VERIFY + +Should return `true` if **password** is likely the **user**'s password, +and `false` otherwise. + +## Flow Events + +These are events which let you alter the behaviour of Omejdn significantly by adding new processing steps or even overwriting parts of Omejdn's flows. +The Token Issuing, Authorization, and Logout Flows are the internal flows started +by invoking the token, authorization, and end-session endpoints respectively. +Separate from these are the OpenID Userinfo endpoint, and various endpoints returning "static" (cachable)data. +For the latter, the plugins are typically invoked with the default response already prepared, +so you can alter directly what is returned. + +### Token Issuing Flow + +These events are fired during an invocation of the token endpoint. +They are listed in the order they are invoked. + +- TOKEN_STARTED + +Fired after the initial authentication of the **client**. +The request parameters **params** are available. + +- TOKEN_UNKNOWN_GRANT_TYPE + +Fired if an unknown grant type was requested +(as indicated in **params** by `params[:grant_type]`) +By default, this will cause Omejdn to return an error. + +If you would like to implement your own grant types, +you should set **custom_grant_type** to `true`, +implement the grant-specific behaviour here +(including setting the **scopes** and **resources**), +and edit the tokens and responses in later events. + +- TOKEN_CREATED_ID_TOKEN + +Fired once the core id token is created. +You may edit the **token** directly. + +- TOKEN_CREATED_ACCESS_TOKEN + +Fired once the core access token is created. +You may edit the **token** directly. + +- TOKEN_FINISHED + +Fired upon successful issuing, before returning the **response** hash to the client. + +### Authorization Flow + +These events are fired during an authorization code flow +(not including the final token request, which is covered in the token flow above). +The order presented here follows loosely the order in which the events are fired. +In some circumstances, events may be fired multiple times, +e.g. when the user is changed and subsequently multiple logins take place. +The flow maintains a context hash called `auth`, which contains the request parameters and any information useful for the authorization, such as the logged in user. + +- AUTHORIZATION_PAR + +Fired when the optional Pushed Authorization Request endpoint is queried and the client successfully authenticates. +The request params are stored in **params** and the request URI is **uri**. + +- AUTHORIZATION_STARTED + +Fired when a valid authorization code flow was started. +**auth** is available, as well as the resolved request parameters **params**. +Resolved means that e.g. `request_uri`s have been resolved to the actual parameters. + +- AUTHORIZATION_LOGIN_STARTED + +Fired when the user has to log in. +This is usually the case when the login was requested by the client, the user was not logged in already or the user decided to switch the account. +**auth** is available. + +If you want to implement other options for login, you may do so by adding a hash to the **login_options** array, containing a `:url` to a custom endpoint implementing the login as well as a `:logo` or `:desc`ription of the option to present to the user. +Call the function `login_finished` with the logged in user and an indication whether the user was just now authenticated to return to the default flow. + +- AUTHORIZATION_LOGIN_FINISHED + +Fired when the **user** has successfully logged in. +**auth** is available. + +- AUTHORIZATION_CONSENT_STARTED + +Fired when the **user** is required to give consent to some **client**. +**auth** is available. +The scope can be found in `auth[:scope]`. + +- AUTHORIZATION_CONSENT_FINISHED + +Fired when the **user** has given consent to a client. +**auth** is available. + +- AUTHORIZATION_FINISHED + +Fired when an authorization decision was made, +right before returning the **response_params** +(including either the `code` or an `error`) to the client. +**auth** is available, but may be incomplete, +if the client was not found or the request was otherwise invalid. + +### OpenID Specific Endpoints + +- OPENID_USERINFO + +Fired when the userinfo endpoint is invoked and authorization was successful. +The hash **userinfo** can be edited directly. + +### Logout Flow + +- LOGOUT_STARTED + +Fired when the end-session endpoint was queried. +The **id_token**, requesting **client**, and approved **redirect_uri** are available. + +- LOGOUT_FINISHED + +Fired when the user was logged out, +right before redirection to **redirect_uri**. + +### Static Endpoints + +These events are fired whenever certain "static" endpoints are invoked. +The responses should take caching into account. +This is not the place to return rapidly changing data. + +- STATIC_JWKS + +Fired when the default `jwks_uri` is invoked. +The **jwks** hash can be altered directly. + +- STATIC_WEBFINGER + +Fired when the webfinger well-known URI is invoked. +The **webfinger** hash can be altered directly. + +- STATIC_METADATA + +Fired when the RFC 8414 Server Metadata well-known URI or the OpenID Connect Discovery endpoint is invoked. +The **metadata** hash can be altered directly. +After all Plugins have been called, the metadata is signed before being returned. + +- STATIC_ABOUT + +Fired when `/about` is invoked. +The hash **about** returns information about the server, such as version or license. + +## Plugin Events + +Plugins may define their own events and call them when appropriate. +These Events should follow the naming convention `PLUGIN__`, with everything capitalized. \ No newline at end of file diff --git a/docs/Plugins/Federation.md b/docs/Plugins/Federation.md new file mode 100644 index 0000000..392aa73 --- /dev/null +++ b/docs/Plugins/Federation.md @@ -0,0 +1,142 @@ +# Authentication Federation + +This Plugin allows to federate authorization decisions to other OpenID Providers (OPs). +Supported are conventional OPs as well as Self-Issued OPs as per the SIOPv2 draft. +The executed flow is the authorization code flow where possible. +For SIOPs, the implicit flow is supported as well. + +Currently, the plugin allows anyone to log in that can log in at the federated OP. +For SIOPs, this is everyone! +However, the plugin allows to add custom functionality to determine the attribute set of any user. +This can be used for access control. + +Configuration comes in two parts: + +1. Provider Configuration determines the OPs which are given as an option to log in +1. Attribute Mappers determine the attributes mapped to foreign users + +Currently, only static configuration is available. +A full configuration (including all optional parameters) looks like this: + +```yaml +plugins: + federation: + providers: + myawesomeop: + issuer: https://example.org/auth + metadata: + insertMetadata: here + self-issued: false + description: Login with My Awesome OP + op_logo_uri: https://example.org/logo.png + token_endpoint_auth_method: client_secret_basic + client_id: myAwesomeClientID + client_secret: myAwesomeClientSecret + scope: + - openid + - profile + attribute_mappers: + - mycustommapper + attribute_mappers: + mycustommapper: + type: static + prerequisites: + sub: + - user01 + - user02 + attributes: + - key: omejdn + value: read +``` + +## Provider Configuration + +Every OP has a unique identifier `:id` and a configuration found under `plugins.federation.:id`. +The configuration options in detail are presented below: + +### Issuer Identifier and OP Server Metadata + +An OP is a SIOP iff `self-issued` is given and set to true. + +Unless the OP is a SIOP, an Issuer Identifier `issuer` MUST be specified. +This issuer identifier is also used to find the relevant metadata document according to RFC 8414 or OIDC Discovery, +unless the metadata document is explicitly given as `metadata`. + +If the OP is a SIOP, then the precedence for determining the metadata document is as follows: + +- If `metadata` is given, it is used as the metadata document +- If `issuer` is given, it is resolved according to RFC 8414 and OIDC Discovery +- The SIOP static discovery metadata document is used + +### OP Selection Attributes + +When the user is asked to log in, they is presented with an option for each OP. +The option shows (in order of highest to lowest precedence): + +- An image found at the URL given by `op_logo_uri` +- The text given by `description` +- The text "Login with `:id`" + +### OIDC Parameters + +The array given as `scope` is used to determine the scopes to request. + +Unless the OP is a SIOP and on-demand registration is to be performed, +the values `token_endpoint_auth_method` and `client_id` have to be provided. + +The following authentication methods are supported: + +- `none` +- `client_secret_basic` (requires specifying a `client_secret`) +- `client_secret_post` (requires specifying a `client_secret`) +- `private_key_jwt` + +Be aware that `private_key_jwt` is only available for non-SIOPs and uses Omejdn's usual signing keys. +You may want to register Omejdn's `jwks_uri` at the OP when using this method. + +### Attribute Mappers + +A list of `attribute_mappers` SHOULD also be specified. +Each one determines the name of an attribute mapper to apply when generating users from this OP. + +## Attribute Mapper Configuration + +Each attribute mapper has a unique `:name`, and a configuration found under `plugins.attribute_mappers.:id`. + +It MUST specify a `type`, which determines the procedure by which attributes are added. +While you may write your own attribute mapper types, +the default types are as follows: + +- `static` maps the specified `attributes` to every user. Example configuration: + +```yaml +staticmapper: + type: static + attributes: + - key: omejdn + value: read +``` + +- `clone` copies attribute values from id token claims to keys specified in the `mapping`. Example Configuration: + +```yaml +clonemapper: + type: clone + mapping: + - from: sub + to: external_sub +``` + +Each attribute mapper may additionally specify some `prerequisites` for it to apply. +These are a list of key-value pairs which are satisfied iff at least one of the specified values appears under the specified key in the userinfo. +This is most useful for OPs that list the user's groups in the userinfo. + +## Writing Custom Mapper Types + +Other plugins may write their own mapper types to support more complex behaviour. + +To implement a new type `:type`, simply register for the event `PLUGIN_FEDERATION_ATTRIBUTE_MAPPING_:TYPE`, +where `:TYPE` is the uppercased `:type`. +The call should return an array of attributes you would like to add to a user. +The configuration for your type can be found via the binding in a local variable `mapper`, +and the userinfo in the local variable `userinfo`. diff --git a/docs/Plugins/Plugin Configuration.md b/docs/Plugins/Plugin Configuration.md new file mode 100644 index 0000000..0886cea --- /dev/null +++ b/docs/Plugins/Plugin Configuration.md @@ -0,0 +1,45 @@ +# Plugin Configuration + +You can add customizable behavior to your plugins by specifying configuration options. +These can be specified in one of two ways. + +## Static Configuration + +When Omejdn is started, it can read YAML files specifying plugins +whose locations are given in the environment variable `OMEJDN_PLUGINS`. +These plugins may come with additional configuration data +available via `PluginLoader.configuration(plugin_name)` +Consider this example `plugins.yml` file: + +```yaml +plugins: + my_awesome_plugin: + foo: 5 + bar: ["hello"] +``` + +This loads a plugin from `plugins/my_awesome_plugin/my_awesome_plugin.rb`. +If said plugin calls `PluginLoader.configuration('my_awesome_plugin')`, +it will receive the following hash: + +```ruby +{ + 'foo' => 5, + 'bar' => ["hello"] +} +``` + +Static configuration files are only read once and never written to. +If you need to change configuration data, +you might want to consider using dynamic configuration instead +(or in addition to static configuration). + +## Dynamic Configuration + +Omejdn provides `Config.read_config(section, fallback)` and `Config.write_config(section, data)`, +which you can use to read and write arrays and hashes from and into the configuration store. + +By convention, your plugin should use its name as section +and initialize the configuration data with default values right after startup. +This will allow other components to find your configuration data +(Think e.g. about Omejdn's Admin UI). \ No newline at end of file diff --git a/docs/Plugins/Plugins.md b/docs/Plugins/Plugins.md new file mode 100644 index 0000000..98554ae --- /dev/null +++ b/docs/Plugins/Plugins.md @@ -0,0 +1,109 @@ +# Omejdn Plugins + +Omejdn provides a mechanism for extending its functionality with plugins. +Plugins are pieces of Ruby code which are executed when so-called events are fired, +and may make arbitrary changes to the program's state. + +There are three types of plugins: + +* **Default Plugins** are plugins that implement necessary but interchangable behavior. + Examples include the `DefaultUserDB` which provides a way of storing + users. + You have to explicitly disable them if you want to replace their + functionality. + They are found in Omejdn's Core code in `/lib`. + +* **Official Plugins** live in the same repository as Omejdn and can be found in `/plugins`. + They offer functionality that can be considered useful in a diverse range of use cases. + Optional OAuth/OpenID functionality is implemented in plugins if it is not near-universally useful. + Examples include the `federation` plugin which allows to delegate identity management. + +* **Custom Plugins** are not part of this repository and typically involve functionality + specific to a particular setup (e.g. a custom user database). + To use them, they need to be copied to `/plugins` (or simply mounted in container environments). + Custom Plugins that become useful to a diverse audience can become Official Plugins. + Please open a corresponding Pull Request at the official Omejdn repository. + +## Activating Plugins + +Plugins can be activated and configured by specifying them in a YAML file +and instructing Omejdn to load it. +The latter can be done by setting the environment variable `OMEJDN_PLUGINS`. + + +The following example file activates two plugins called `user_backend_sqlite` and `admin_api`, +configures the former to use a `location` property of `config/users.yml`, +and deactivates the DefaultUserDB plugin. +How plugins use their configuration values is up to the individual plugins +and *should* be documented. + +```yaml +deactivate_defaults: +- user +plugins: + user_backend_sqlite: + location: config/users.yml + admin_api: +``` + +Each explicitly activated plugin lives in a folder corresponding to its name in `/plugins`. +The main Ruby file must have the same name as the plugin. + +For example: The main plugin file for the `admin_api` plugin +is located at `/plugins/admin_api/admin_api.rb`. + +## Writing Plugins + +Plugin main files are loaded and executed upon startup. +You may *require* other files as approproate. +The files are executed on the top-level, +which implies that you have access to the full functionality that Omejdn +and its dependencies provide. + +For example, you can register endpoints using Omejdn's `endpoint` function +and have access to the user's `session` hash for storing session data. + +You can also register functions to be executed whenever a certain *Event* is fired. +An example event is `TOKEN_CREATED_ACCESS_TOKEN`, +which is fired whenever Omejdn creates an access token. +You then have the option to hook into the code and e.g. modify the token. +See [Events](./Events.md) for a listing of all available events. + +The following code demonstrates how to subscribe to a certain event: + +```ruby +def my_awesome_function(bind) + # This is called every time the TOKEN_CREATED_ACCESS_TOKEN event is fired +end + +PluginLoader.register('TOKEN_CREATED_ACCESS_TOKEN', :my_awesome_function) +``` + +The registered function is called with a single argument: The binding of the caller. +This binding allows you to modify the caller's environment. +For example, to get a local variable `var`, you can use + +```ruby +var = bind.local_variable_get(:var) +``` + +To set a local variable `var` to `5`, you can use + +```ruby +bind.local_variable_set(:var, 5) +``` + +Please have a look at the Ruby documentation for more information on bindings. + +### Best Practice for writing plugins + +When writing plugins, try to keep it compatible with other plugins. +In particular, you should avoid cluttering the top-level with symbols +such as helper functions that may conflict with functionality in other plugins. + +Always try to use functionality that is already provided by Omejdn +rather than implementing it yourself. + +Ruby bindings provide a method called `eval` executing arbitrary code given as a string. +Be extremely cautious when using it and make sure to sanitize any inputs to avoid +arbitrary code execution exploits. diff --git a/docs/Plugins/Postgres Backend.md b/docs/Plugins/Postgres Backend.md new file mode 100644 index 0000000..0c8c0ea --- /dev/null +++ b/docs/Plugins/Postgres Backend.md @@ -0,0 +1,65 @@ +# Postgres Storage Backend + +The official `postgres_backend` plugin provides the means to store Omejdn's data +in a persistent storage backend, +thanks to the magic of Postgres. + +## Configuration + +Since any configuration is stored inside this plugin, +there is no dynamic configuration. +An example static configuration to activate the plugin looks like this: + +```yaml +plugins: + postgres_backend: + connection: + host: localhost + port: 5432 + dbname: omejdn + user: myuser + password: mypassword + handlers: + - keys + - config + - client + - user +deactivate_defaults: + - config + - keys + - client + - user +``` + +The plugin can replace the default plugins. +Therefore you want to specify all activated handlers in `deactivate_defaults`, +so Omejdn does not use more than one backend. +It is still possible to leave the default `user` and `client` DBs active. +In this case, do not forget to set a `user_backend_default` and +`client_backend_default` in the configuration, +so Omejdn knows which plugin to use for new users and clients. + + +The configuration options in detail: + +- **connection** specifies the connection to the Postgres instance. + You may use any of the values defined [here](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS). + A few intuitive options are given in the example above. +- **handlers** specifies a list of handlers to register. + The full list is shown in the example above. + If you omitt this option, the default is to register all event handlers. + +## Database Layout + +If you would like to manipulate the database yourself, you can do so. +The following overview of where to find what should get you started. + +- Users are saved with username and (hashed) password in `users` +- Clients have their metadata stored in `clients` +- Both Users and Clients can have attributes. + These are saved in `attributes` +- Cryptographic keys and certificates are stored in `keys`. + A type of `sk` indicates a private key, while `certs` are certificate chains. +- For each configuration section, there is a separate relation in the database + named `configuration_
`. + The main configuration e.g. can be found in `configuration_omejdn`. diff --git a/docs/Plugins/Writing Plugins.md b/docs/Plugins/Writing Plugins.md deleted file mode 100644 index d0dcd4b..0000000 --- a/docs/Plugins/Writing Plugins.md +++ /dev/null @@ -1,44 +0,0 @@ -# Omejdn Plugins - -You can add your own plugins in the corresponding folder. - -Omejdn supports the following types of plugins: - -* **user_db**: Storage backends for users -* **claim_mapper**: Mapping claims from and to user/client attributes -* **api**: Additional Endpoints and APIs - -Plugins can be activated by specifying them in `omejdn.yml`: - -```yaml -plugins: - user_db: - yaml: - ldap: - api: - selfservice: - admin: -``` - -Plugin files are loaded upon startup. To be able to call their functionality, -Claim Mapper and User DB plugins must return an appropriate class through a function `load_{type}_{name}` in the `PluginLoader`. -It takes one argument: The configuration from the Configuration file, -which the plugin should supplement by reasonable default values. -Here is an example from the LDAP User DB Plugin: - -```ruby -class PluginLoader - def self.load_user_db_ldap(config) - LdapUserDb.new config - end -end -``` - -The corresponding abstract class can be found in the file `_abstract.rb`. -You might want to include it in your plugin like so: - -```ruby -require_relative './_abstract' -``` - -Likewise, if your plugin depends on other plugins, you should require them. diff --git a/docs/Plugins/omejdn_persistent_state.svg b/docs/Plugins/omejdn_persistent_state.svg new file mode 100644 index 0000000..e8615c8 --- /dev/null +++ b/docs/Plugins/omejdn_persistent_state.svg @@ -0,0 +1,4 @@ + + + +
Config
Config
DefaultConfigDB
DefaultConfigDB
<Other Plugins>
<Other Plugins>
Client
Client
DefaultClientDB
DefaultClientDB
<Other Plugins>
<Other Plugins>
User
User
DefaultUserDB
DefaultUserDB
<Other Plugins>
<Other Plugins>
Client Metadata
and Attributes
Client Metadata...
User Data
and Attributes
User Data...
Keys
Keys
DefaultKeysDB
DefaultKeysDB
<Other Plugins>
<Other Plugins>
Client
Authentication
Certificates
Client...
YAML
/config
YAML...
PEM
/keys
PEM...
Omejdn
Omejdn
Server
Signing
Keys
Server...
Server
Configuration
Data
Server...
CLIENT_*
CLIENT_*
USER_*
USER_*
CONFIGURATION_*
CONFIGURATION_*
KEYS_*
KEYS_*
Text is not SVG - cannot display
\ No newline at end of file diff --git a/keys/clients/.gitkeep b/keys/clients/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/keys/omejdn/.gitkeep b/keys/omejdn/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/client.rb b/lib/client.rb index 3e8a56c..20acbac 100644 --- a/lib/client.rb +++ b/lib/client.rb @@ -1,71 +1,62 @@ # frozen_string_literal: true -# OAuth Client +require_relative './plugins' + +# Class representing an OAuth Client class Client - attr_accessor :client_id, :metadata, :attributes + attr_accessor :metadata, :attributes, :backend + + # ----- Implemented by plugins ----- def self.find_by_id(client_id) - load_clients.each do |client| - return client if client_id == client.client_id - end - nil - end - - def apply_values(ccnf) - @client_id = ccnf.delete('client_id') - @attributes = ccnf.delete('attributes') || [] - @metadata = ccnf - end - - def self.load_clients - needs_save = false - clients = Config.client_config.map do |ccnf| - import = ccnf.delete('import_certfile') - client = Client.new - client.apply_values(ccnf) - if import - begin - client.certificate = OpenSSL::X509::Certificate.new File.read import - needs_save = true - rescue StandardError => e - p "Unable to load key ``#{import}'': #{e}" - end - end - client - end - Config.client_config = clients if needs_save - clients - end - - def self.from_dict(json) - client = Client.new - client.apply_values(json) - client + PluginLoader.fire('CLIENT_GET', binding).flatten.compact.first end - # Decodes a JWT - def decode_jwt(jwt, verify_aud) - jwt_dec, jwt_hdr = JWT.decode(jwt, nil, false) # Decode without verify - aud = Config.base_config['accept_audience'] - raise 'Not self-issued' if jwt_dec['sub'] && jwt_dec['sub'] != jwt_dec['iss'] - raise 'Invalid algorithm' unless %w[RS256 RS512 ES256 ES512].include? jwt_hdr['alg'] + def self.all_clients + PluginLoader.fire('CLIENT_GET_ALL', binding).flatten + end - jwt_dec, = JWT.decode jwt, certificate&.public_key, true, - { nbf_leeway: 30, aud: aud, verify_aud: verify_aud, algorithm: jwt_hdr['alg'] } + def self.add_client(client, client_backend) + PluginLoader.fire('CLIENT_CREATE', binding) + end - raise 'Not self-issued' if jwt_dec['sub'] && jwt_dec['sub'] != jwt_dec['iss'] - raise 'Wrong Client ID in JWT' if jwt_dec['sub'] && jwt_dec['sub'] != @client_id + def self.delete_client(client_id) + PluginLoader.fire('CLIENT_DELETE', binding) + end - jwt_dec - rescue StandardError => e - puts "Error decoding JWT #{jwt}: #{e}" - raise OAuthError.new 'invalid_client', "Error decoding JWT: #{e}" + def save + client = self + PluginLoader.fire('CLIENT_UPDATE', binding) end - def to_dict - result = { 'client_id' => @client_id }.merge(@metadata) - result['attributes'] = @attributes - result.compact + def certificate + client = self + PluginLoader.fire('CLIENT_AUTHENTICATION_CERTIFICATE_GET', binding).compact.first + end + + def certificate=(new_cert) + client = self + PluginLoader.fire('CLIENT_AUTHENTICATION_CERTIFICATE_UPDATE', binding) + end + + # ----- Conversion to/from hash for import/export ----- + + def self.from_h(dict) + client = Client.new + client.attributes = dict.delete('attributes') || [] + client.metadata = dict + client + end + + def to_h + { + 'attributes' => @attributes + }.merge(@metadata).compact + end + + def claim?(searchkey, searchvalue = nil) + attribute = attributes.select { |a| a['key'] == searchkey }.first + !attribute.nil? && (searchvalue.nil? || attribute['value'] == searchvalue) end def filter_scopes(scopes) @@ -105,37 +96,100 @@ def verify_post_logout_redirect_uri(uri) return uri if [*@metadata['post_logout_redirect_uris']].include? escaped_redir end - def claim?(searchkey, searchvalue = nil) - attribute = attributes.select { |a| a['key'] == searchkey }.first - !attribute.nil? && (searchvalue.nil? || attribute['value'] == searchvalue) - end + # Decodes a JWT + def decode_jwt(jwt, verify_aud) + aud = Config.base_config['accept_audience'] + jwt_dec, = JWT.decode jwt, certificate&.public_key, true, + { nbf_leeway: 30, aud: aud, verify_aud: verify_aud, algorithm: %w[RS256 RS512 ES256 ES512] } + + raise 'Not self-issued' if jwt_dec['sub'] && jwt_dec['sub'] != jwt_dec['iss'] + raise 'Wrong Client ID in JWT' if jwt_dec['sub'] && jwt_dec['sub'] != client_id - def certificate_file - "keys/clients/#{Base64.urlsafe_encode64(@client_id)}.cert" + jwt_dec + rescue StandardError => e + puts "Error decoding JWT #{jwt}: #{e}" + raise OAuthError.new 'invalid_client', "Error decoding JWT: #{e}" end - def certificate - cert = OpenSSL::X509::Certificate.new File.read certificate_file - raise 'Certificate expired' if cert.not_after < Time.now - raise 'Certificate not yet valid' if cert.not_before > Time.now + # ----- Util ----- - cert - rescue StandardError => e - p "Unable to load key ``#{certificate_file}'': #{e}" - nil + # For convenience, make the client_id a symbol + def client_id + @metadata['client_id'] end - def certificate=(new_cert) - # delete the certificate if set to nil - filename = certificate_file - if new_cert.nil? - File.delete filename if File.exist? filename - return - end - File.write(filename, new_cert) + def client_id=(_new_cid) + @metadata['client_id'] = newcid end + # client_ids are the primary key for clients def ==(other) client_id == other.client_id end end + +# The default Client DB saves Client Configuration in a dedicated configuration section. +# The exception to this rule are certificates, which are stored in keys/clients/ +# in PEM encoded form. +class DefaultClientDB + def self.get(bind) + client_id = bind.local_variable_get :client_id + clients = get_all + idx = clients.index Client.from_h({ 'client_id' => client_id }) + idx ? clients[idx] : nil + end + + def self.get_all(*) + Config.client_config.map { |ccnf| Client.from_h ccnf } + end + + def self.create(bind) + new_client = bind.local_variable_get :client + clients = get_all + clients << new_client + Config.client_config = clients.map(&:to_h) + end + + def self.update(bind) + client = bind.local_variable_get :client + clients = get_all + idx = clients.index client + clients[idx] = client if idx + Config.client_config = clients.map(&:to_h) + end + + def self.delete(bind) + client_id = bind.local_variable_get :client_id + clients = get_all + idx = clients.index Client.from_h({ 'client_id' => client_id }) + clients.delete_at idx if idx + Config.client_config = clients.map(&:to_h) + end + + def self.certificate_get(bind) + client = bind.local_variable_get :client + key_material = Keys.load_key KEYS_TARGET_CLIENT, client.client_id + key_material&.dig('certs', 0) + end + + def self.certificate_update(bind) + client = bind.local_variable_get :client + new_cert = bind.local_variable_get :new_cert + hash = Keys.load_key KEYS_TARGET_CLIENT, client.client_id + hash['certs'] = new_cert ? [new_cert] : nil + hash = {} unless hash['sk'] || hash['certs'] + hash['pk'] = (hash['sk'] || hash.dig('certs', 0))&.public_key + Keys.store_key KEYS_TARGET_CLIENT, client.client_id, hash.compact + end + + # register functions + def self.register + PluginLoader.register 'CLIENT_GET', method(:get) + PluginLoader.register 'CLIENT_GET_ALL', method(:get_all) + PluginLoader.register 'CLIENT_CREATE', method(:create) + PluginLoader.register 'CLIENT_UPDATE', method(:update) + PluginLoader.register 'CLIENT_DELETE', method(:delete) + PluginLoader.register 'CLIENT_AUTHENTICATION_CERTIFICATE_GET', method(:certificate_get) + PluginLoader.register 'CLIENT_AUTHENTICATION_CERTIFICATE_UPDATE', method(:certificate_update) + end +end diff --git a/lib/config.rb b/lib/config.rb index 7e464f1..408dbeb 100644 --- a/lib/config.rb +++ b/lib/config.rb @@ -1,93 +1,135 @@ # frozen_string_literal: true require 'yaml' -OMEJDN_CONFIG_DIR = 'config' -OMEJDN_BASE_CONFIG_FILE = "#{OMEJDN_CONFIG_DIR}/omejdn.yml" -OMEJDN_CLIENT_CONFIG_FILE = "#{OMEJDN_CONFIG_DIR}/clients.yml" -OMEJDN_OAUTH_PROVIDER_CONFIG = "#{OMEJDN_CONFIG_DIR}/oauth_providers.yml" -SCOPE_DESCRIPTION_CONFIG = "#{OMEJDN_CONFIG_DIR}/scope_description.yml" -SCOPE_MAPPING_CONFIG = "#{OMEJDN_CONFIG_DIR}/scope_mapping.yml" -WEBFINGER_CONFIG = "#{OMEJDN_CONFIG_DIR}/webfinger.yml" +CONFIG_SECTION_OMEJDN = 'omejdn' +CONFIG_SECTION_CLIENTS = 'clients' +CONFIG_SECTION_USERS = 'users' +CONFIG_SECTION_OAUTH_PROVIDERS = 'oauth_providers' +CONFIG_SECTION_SCOPE_DESCRIPTION = 'scope_description' +CONFIG_SECTION_SCOPE_MAPPING = 'scope_mapping' +CONFIG_SECTION_WEBFINGER = 'webfinger' +DEFAULT_SCOPE_MAPPING = { + # Omejdn API scopes + 'omejdn:read' => ['omejdn'], + 'omejdn:write' => ['omejdn'], + 'omejdn:admin' => ['omejdn'], + # OpenID scopes + 'profile' => %w[name family_name given_name middle_name nickname preferred_username profile picture + website gender birthdate zoneinfo locale updated_at], + 'email' => %w[email email_verified], + 'address' => %w[formatted street_address locality region postal_code country], + 'phone' => %w[phone_number phone_number_verified] +}.freeze +DEFAULT_SCOPE_DESCRIPTION = { + 'omejdn:read' => 'Read access to the Omejdn server API', + 'omejdn:write' => 'Write access to the Omejdn server API', + 'omejdn:admin' => 'Access to the Omejdn server admin API', + 'profile' => 'Standard profile claims (e.g.: Name, picture, website, gender, birthdate, location)', + 'email' => 'Email-Address', + 'address' => 'Address', + 'phone' => 'Phone-number' +}.freeze # Configuration helpers functions class Config - def self.write_config(file, data) - file = File.new file, File::CREAT | File::TRUNC | File::RDWR - file.write data - file.close + def self.write_config(section, data) + PluginLoader.fire('CONFIGURATION_STORE', binding) end - def self.read_config(file, fallback) - (YAML.safe_load (File.read file), fallback: fallback, filename: file) || fallback + def self.read_config(section, fallback) + PluginLoader.fire('CONFIGURATION_LOAD', binding).first end def self.client_config - read_config OMEJDN_CLIENT_CONFIG_FILE, [] + read_config CONFIG_SECTION_CLIENTS, [] + end + + def self.client_config=(config) + write_config(CONFIG_SECTION_CLIENTS, config) end - def self.client_config=(clients) - clients_yaml = clients.map(&:to_dict) - write_config(OMEJDN_CLIENT_CONFIG_FILE, clients_yaml.to_yaml) + def self.user_config + read_config CONFIG_SECTION_USERS, [] + end + + def self.user_config=(config) + write_config(CONFIG_SECTION_USERS, config) end def self.base_config - read_config OMEJDN_BASE_CONFIG_FILE, {} + read_config CONFIG_SECTION_OMEJDN, {} end def self.base_config=(config) - write_config OMEJDN_BASE_CONFIG_FILE, config.to_yaml + write_config CONFIG_SECTION_OMEJDN, config end def self.oauth_provider_config - read_config OMEJDN_OAUTH_PROVIDER_CONFIG, [] + read_config CONFIG_SECTION_OAUTH_PROVIDERS, [] end def self.oauth_provider_config=(providers) - write_config(OMEJDN_OAUTH_PROVIDER_CONFIG, providers.to_yaml) + write_config(CONFIG_SECTION_OAUTH_PROVIDERS, providers) end def self.scope_description_config - read_config SCOPE_DESCRIPTION_CONFIG, {} + read_config CONFIG_SECTION_SCOPE_DESCRIPTION, {} + end + + def self.scope_description_config=(config) + write_config(CONFIG_SECTION_SCOPE_DESCRIPTION, config) end def self.scope_mapping_config - read_config SCOPE_MAPPING_CONFIG, {} + read_config CONFIG_SECTION_SCOPE_MAPPING, {} + end + + def self.scope_mapping_config=(config) + write_config(CONFIG_SECTION_SCOPE_MAPPING, config) end def self.webfinger_config - read_config WEBFINGER_CONFIG, {} + read_config CONFIG_SECTION_WEBFINGER, {} end def self.webfinger_config=(config) - write_config(WEBFINGER_CONFIG, config.to_yaml) + write_config(CONFIG_SECTION_WEBFINGER, config) end - # Fill missing values in the main configuration + # Fill missing values in the configuration + # This will create a configuration if necessary def self.setup + # Load existing configuration config = base_config + + # Fill in default values apply_env(config, 'issuer', 'http://localhost:4567') apply_env(config, 'front_url', config['issuer']) apply_env(config, 'bind_to', '0.0.0.0:4567') apply_env(config, 'environment', 'development') apply_env(config, 'openid', false) - apply_env(config, 'default_audience', '') - apply_env(config, 'accept_audience', config['issuer']) + apply_env(config, 'default_audience', []) + apply_env(config, 'accept_audience', [config['issuer'], "#{config['front_url']}/token"]) %w[access_token id_token].each do |token| apply_env(config, "#{token}.expiration", 3600) apply_env(config, "#{token}.algorithm", 'RS256') end - has_user_db_configured = config.dig('plugins', 'user_db') && !config.dig('plugins', 'user_db').empty? - if ENV.fetch('OMEJDN_ADMIN', nil) && !has_user_db_configured - # Try to enable yaml plugin, to have at least one user_db - config['plugins'] ||= {} - config['plugins']['user_db'] = { 'yaml' => nil } - has_user_db_configured = true - end - if config['openid'] && !has_user_db_configured - puts 'ERROR: No user_db plugin defined. Cannot serve OpenID functionality' - exit - end - apply_env(config, 'user_backend_default', config.dig('plugins', 'user_db').keys.first) if has_user_db_configured + + # Scope Mapping + scope_mapping = scope_mapping_config + scope_mapping = DEFAULT_SCOPE_MAPPING if scope_mapping.empty? + Config.scope_mapping_config = scope_mapping + + # Scope Description + scope_description = scope_description_config + scope_description = DEFAULT_SCOPE_DESCRIPTION if scope_description.empty? + Config.scope_description_config = scope_description + + # Webfinger (Fallback is default) + webfinger = webfinger_config + Config.webfinger_config = webfinger + + # Save base configuration and return it Config.base_config = config end @@ -116,12 +158,40 @@ def self.create_admin admin_name, admin_pw = ENV['OMEJDN_ADMIN'].split(':') admin = User.find_by_id(admin_name) unless admin - admin = User.from_dict({ - 'username' => admin_name, - 'attributes' => [{ 'key' => 'omejdn', 'value' => 'admin' }] - }) + admin = User.from_h({ + 'username' => admin_name, + 'attributes' => [{ 'key' => 'omejdn', 'value' => 'admin' }] + }) User.add_user(admin, Config.base_config['user_backend_default']) end admin.update_password(admin_pw) end end + +# DefaultConfigDB saves Configuration Data as YAML files on disk +class DefaultConfigDB + CONFIG_DIR = 'config' + + def self.write_config(bind) + section = bind.local_variable_get :section + data = bind.local_variable_get :data + file = File.new "#{CONFIG_DIR}/#{section}.yml", File::CREAT | File::TRUNC | File::RDWR + file.write data.to_yaml + file.close + end + + def self.read_config(bind) + section = bind.local_variable_get :section + fallback = bind.local_variable_get :fallback + return fallback unless File.exist? "#{CONFIG_DIR}/#{section}.yml" + + (YAML.safe_load (File.read "#{CONFIG_DIR}/#{section}.yml"), fallback: fallback, + filename: "#{CONFIG_DIR}/#{section}.yml") || fallback + end + + # register functions + def self.register + PluginLoader.register 'CONFIGURATION_STORE', method(:write_config) + PluginLoader.register 'CONFIGURATION_LOAD', method(:read_config) + end +end diff --git a/lib/keys.rb b/lib/keys.rb index cf7a171..9ad5934 100644 --- a/lib/keys.rb +++ b/lib/keys.rb @@ -1,29 +1,124 @@ # frozen_string_literal: true -require_relative './config' require 'openssl' require 'jwt' +KEYS_TARGET_OMEJDN = 'omejdn' +KEYS_TARGET_CLIENT = 'clients' # Key and Certificate Management class Keys - def self.setup_skey(filename) - rsa_key = OpenSSL::PKey::RSA.new 2048 - file = File.new filename, File::CREAT | File::TRUNC | File::RDWR - file.write(rsa_key.to_pem) - file.close - p "Created new key at #{filename}" + # Stores a cryptographic key (pk or sk) and/or certificates + def self.store_key(target_type, target, key_material) + PluginLoader.fire('KEYS_STORE', binding) + end + + # Loads a cryptographic key (sk where available) and/or certificates + def self.load_key(target_type, target, create_key: false) + key_material = PluginLoader.fire('KEYS_LOAD', binding).first + if key_material['sk'].nil? && create_key + (key_material = {})['sk'] = OpenSSL::PKey::RSA.new 2048 + key_material['pk'] = key_material['sk'].public_key + store_key(target_type, target, key_material) + end + key_material['kid'] = JWT::JWK.new(key_material['pk']).export[:kid] if key_material['pk'] + key_material.compact + end + + # Loads all available keys and certificates for a target_type + # May contain duplicates and expired certificates + def self.load_all_keys(target_type) + PluginLoader.fire('KEYS_LOAD_ALL', binding).flatten end def self.gen_x5c(certs) - certs.map { |cert| Base64.encode64(cert.to_der).strip } + certs.map { |cert| Base64.strict_encode64(cert.to_der).strip } end def self.gen_x5t(certs) Base64.urlsafe_encode64(OpenSSL::Digest::SHA1.new(certs[0].to_der).to_s) end - def self.load_pkey - Dir.entries('keys/omejdn').reject { |f| f.start_with? '.' }.map do |f| + def self.generate_jwks + { keys: (load_all_keys(KEYS_TARGET_OMEJDN).map do |k| + jwk = JWT::JWK.new(k['pk']).export + jwk[:use] = 'sig' + if k['certs'] + jwk[:x5c] = gen_x5c(k['certs']) + jwk[:x5t] = gen_x5t(k['certs']) + end + jwk + end).uniq { |k| k[:kid] } } + end +end + +# The default Keys DB stores keys and certificates as PEM in /keys +class DefaultKeysDB + KEYS_DIR = 'keys' + + def self.store_key(bind) + target_type = bind.local_variable_get :target_type + target = bind.local_variable_get :target + key_material = bind.local_variable_get :key_material + filename = "#{KEYS_DIR}/#{target_type}/#{target}" + + # Ensure the directory exists + if (key_material['certs'] || key_material['sk']) && !(File.directory? "#{KEYS_DIR}/#{target_type}") + Dir.mkdir "#{KEYS_DIR}/#{target_type}" + end + + # Certificates + if key_material['certs'].nil? + File.delete "#{filename}.cert" if File.exist? "#{filename}.cert" + else + pem = key_material['certs'].map(&:to_pem).join("\n") + File.write("#{filename}.cert", pem) + end + + # Keys + if key_material['sk'].nil? + File.delete "#{filename}.key" if File.exist? "#{filename}.key" + else + File.write("#{filename}.key", key_material['sk']) + end + end + + def self.load_key(bind) + target_type = bind.local_variable_get :target_type + target = bind.local_variable_get :target + result = {} + filename = "#{KEYS_DIR}/#{target_type}/#{target}" + + # Try to load keys + if File.exist?("#{filename}.key") + begin + key = OpenSSL::PKey::RSA.new File.read("#{filename}.key") + result['sk'] = key if key.private? + result['pk'] = key.private? ? key.public_key : key + rescue StandardError + p 'Loading key failed' + end + end + + # Try to load certificate (chain) + if File.exist?("#{filename}.cert") + begin + certs = OpenSSL::X509::Certificate.load_file("#{filename}.cert") + raise 'Certificate expired' if certs[0].not_after < Time.now + raise 'Certificate not yet valid' if certs[0].not_before > Time.now + + result['certs'] = certs if result['sk'].nil? || (certs[0].check_private_key result['sk']) + rescue StandardError + p 'Loading certificate failed' + end + end + result + end + + def self.load_all_keys(bind) + target_type = bind.local_variable_get :target_type + return [] unless File.directory? "#{KEYS_DIR}/#{target_type}" + + Dir.entries("#{KEYS_DIR}/#{target_type}").reject { |f| f.start_with? '.' }.map do |f| result = {} # The file could be either a certificate or a key begin @@ -37,24 +132,10 @@ def self.load_pkey end end - def self.load_skey - filename = 'keys/omejdn/omejdn.key' - setup_skey(filename) unless File.exist? filename - sk = OpenSSL::PKey::RSA.new File.read(filename) - pk = load_pkey.select { |c| c.dig('certs', 0) && (c.dig('certs', 0).check_private_key sk) }.first - kid = JWT::JWK.new(sk.public_key).export[:kid] - (pk || {}).merge({ 'sk' => sk, 'pk' => sk.public_key, 'kid' => kid }) - end - - def self.generate_jwks - { keys: (load_pkey.map do |k| - jwk = JWT::JWK.new(k['pk']).export - jwk[:use] = 'sig' - if k['certs'] - jwk[:x5c] = gen_x5c(k['certs']) - jwk[:x5t] = gen_x5t(k['certs']) - end - jwk - end).uniq { |k| k[:kid] } } + # register functions + def self.register + PluginLoader.register 'KEYS_STORE', method(:store_key) + PluginLoader.register 'KEYS_LOAD', method(:load_key) + PluginLoader.register 'KEYS_LOAD_ALL', method(:load_all_keys) end end diff --git a/lib/oauth_helper.rb b/lib/oauth_helper.rb index 793a976..a207776 100644 --- a/lib/oauth_helper.rb +++ b/lib/oauth_helper.rb @@ -34,7 +34,7 @@ def self.authenticate_client(params, auth_header) # Determine the client, trusting it will use the correct method to tell us client_id = params[:client_id] if auth_header.start_with? 'Basic' - client_id, client_secret = Base64.decode64(auth_header.slice(6..-1)).split(':', 2) + client_id, client_secret = Base64.strict_decode64(auth_header.slice(6..-1)).split(':', 2) end if params[:client_assertion_type] == 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' client_id = JWT.decode(params[:client_assertion], nil, false).dig(0, 'sub') # Decode without verify @@ -148,13 +148,17 @@ def self.map_claims_to_userinfo(attrs, claims, client, scopes) end def self.validate_pkce(code_challenge, code_verifier, method) + expected_challenge = generate_pkce(code_verifier, method) + raise OAuthError.new 'invalid_request', 'Code verifier mismatch' unless expected_challenge == code_challenge + end + + def self.generate_pkce(code_verifier, method) raise OAuthError.new 'invalid_request', "Unsupported verifier method: #{method}" unless method == 'S256' raise OAuthError.new 'invalid_request', 'Code verifier missing' if code_verifier.nil? digest = Digest::SHA256.new digest << code_verifier - expected_challenge = digest.base64digest.gsub('+', '-').gsub('/', '_').gsub('=', '') - raise OAuthError.new 'invalid_request', 'Code verifier mismatch' unless expected_challenge == code_challenge + digest.base64digest.gsub('+', '-').gsub('/', '_').gsub('=', '') end def self.configuration_metadata_oidc_discovery(base_config, path) @@ -245,17 +249,14 @@ def self.configuration_metadata # OpenID Connect Discovery 1.0 metadata.merge!(configuration_metadata_oidc_discovery(base_config, path)) - - # Signing as per RFC 8414 - metadata['signed_metadata'] = sign_metadata metadata - metadata end def self.sign_metadata(metadata) to_sign = metadata.merge to_sign['iss'] = to_sign['issuer'] - key_pair = Keys.load_skey - JWT.encode to_sign, key_pair['sk'], 'RS256', { kid: key_pair['kid'] } + key_pair = Keys.load_key KEYS_TARGET_OMEJDN, 'omejdn', create_key: true + metadata['signed_metadata'] = JWT.encode to_sign, key_pair['sk'], 'RS256', { kid: key_pair['kid'] } + metadata end def self.adapt_requested_claims(req_claims) diff --git a/lib/plugins.rb b/lib/plugins.rb index a78cb5e..be5db94 100644 --- a/lib/plugins.rb +++ b/lib/plugins.rb @@ -1,27 +1,45 @@ # frozen_string_literal: true -# Extend this by an appropriate function to load a Plugin +# Handles calling plugins class PluginLoader + class << self; attr_accessor :listeners end + @configuration = {} # The plugin configuration + @listeners = {} # A mapping from events to a list of listener functions + # Load all relevant files def self.initialize - (Config.base_config['plugins'] || {}).each do |type, plugins| - plugins.each do |name, _plugin_config| - puts "Loading Plugin (#{type}): #{name}" - require_relative "./../plugins/#{type}/#{name}" - end + (ENV.fetch('OMEJDN_PLUGINS', nil) || '').split(':').each do |conffile| + @configuration.merge!(YAML.safe_load(File.read(conffile), fallback: {}, filename: ARGV[1]) || {}) + end + + # Load Custom Plugins + (@configuration['plugins'] || {}).each do |plugin, _config| + puts "Loading Plugin: #{plugin}" + require_relative "./../plugins/#{plugin}/#{plugin}" end + + # Register default plugins + default_plugins = %w[user client config keys] - (@configuration['deactivate_defaults'] || []) + DefaultClientDB.register if default_plugins.include? 'client' + DefaultUserDB.register if default_plugins.include? 'user' + DefaultConfigDB.register if default_plugins.include? 'config' + DefaultKeysDB.register if default_plugins.include? 'keys' end - # Load one particular Plugin - # Should return the corresponding interface for that type - def self.load_plugin(type, name) - public_send("load_#{type}_#{name}", Config.base_config.dig('plugins', type, name)) + # Returns any specified configuration options for the plugin + def self.configuration(plugin) + @configuration.dig('plugins', plugin) || {} end - # Load all plugins of a type - def self.load_plugins(type) - (Config.base_config.dig('plugins', type) || []).map do |name, plugin_config| - public_send("load_#{type}_#{name}", plugin_config) - end + # Register a listener + def self.register(event, listener) + # p "Plugins: registering #{listener} for event #{event}" + (@listeners[event] ||= []) << listener + end + + # Call all listeners and return their values + def self.fire(event, bind) + # p "Plugins: Firing event #{event}" + (@listeners[event] || []).map { |l| l.call(bind) } end end diff --git a/lib/token.rb b/lib/token.rb index 52d9a6e..590b91a 100644 --- a/lib/token.rb +++ b/lib/token.rb @@ -25,13 +25,11 @@ def self.access_token(client, user, scopes, claims, resources) 'exp' => now + base_config.dig('access_token', 'expiration'), 'client_id' => client.client_id } - PluginLoader.load_plugins('claim_mapper').each do |mapper| - token.merge!(mapper.map_to_access_token(client, user, scopes, claims['access_token'], resources)) - end + PluginLoader.fire 'TOKEN_CREATED_ACCESS_TOKEN', binding reserved = {} reserved['userinfo_req_claims'] = claims['userinfo'] unless (claims['userinfo'] || {}).empty? token['omejdn_reserved'] = reserved unless reserved.empty? - key_pair = Keys.load_skey + key_pair = Keys.load_key KEYS_TARGET_OMEJDN, 'omejdn', create_key: true JWT.encode token, key_pair['sk'], 'RS256', { typ: 'at+jwt', kid: key_pair['kid'] } end @@ -49,10 +47,8 @@ def self.id_token(client, user, scopes, claims, nonce) 'auth_time' => user.auth_time, 'nonce' => nonce }.compact - PluginLoader.load_plugins('claim_mapper').each do |mapper| - token.merge!(mapper.map_to_id_token(client, user, scopes, claims['id_token'])) - end - key_pair = Keys.load_skey + PluginLoader.fire 'TOKEN_CREATED_ID_TOKEN', binding + key_pair = Keys.load_key KEYS_TARGET_OMEJDN, 'omejdn', create_key: true JWT.encode token, key_pair['sk'], 'RS256', { typ: 'JWT', kid: key_pair['kid'] } end @@ -62,6 +58,6 @@ def self.decode(token, endpoint = nil) args = { algorithm: Config.base_config.dig('access_token', 'algorithm') } args.merge!({ aud: "#{Config.base_config['front_url']}#{endpoint}", verify_aud: true }) if endpoint - JWT.decode(token, Keys.load_skey['sk'].public_key, true, args)[0] + JWT.decode(token, Keys.load_key(KEYS_TARGET_OMEJDN, 'omejdn')['sk'].public_key, true, args)[0] end end diff --git a/lib/user.rb b/lib/user.rb index bcf1d72..714a5ee 100644 --- a/lib/user.rb +++ b/lib/user.rb @@ -5,92 +5,164 @@ # Class representing a user from a DB class User - attr_accessor :username, :password, :attributes, :extern, :backend, :auth_time + attr_accessor :username, :password, :attributes, :extern, :backend, :auth_time, :consent - def verify_password(pass) - PluginLoader.load_plugin('user_db', backend)&.verify_password(self, pass) || false + # ----- Implemented by plugins ----- + + def self.find_by_id(username) + PluginLoader.fire('USER_GET', binding).flatten.compact.first end def self.all_users - PluginLoader.load_plugins('user_db').map(&:all_users).flatten + PluginLoader.fire('USER_GET_ALL', binding).flatten end - def self.find_by_id(username) - PluginLoader.load_plugins('user_db').each do |db| - user = db.find_by_id(username) - return user unless user.nil? - end - nil + def self.add_user(user, user_backend) + PluginLoader.fire('USER_CREATE', binding) + end + + def self.delete_user(username) + PluginLoader.fire('USER_DELETE', binding) + end + + def save + user = self + PluginLoader.fire('USER_UPDATE', binding) + end + + def verify_password(password) + user = self + PluginLoader.fire('USER_AUTHENTICATION_PASSWORD_VERIFY', binding).compact.first || false + end + + def update_password(password) + user = self + PluginLoader.fire('USER_AUTHENTICATION_PASSWORD_CHANGE', binding) end - def self.from_dict(dict) + # ----- Conversion to/from hash for import/export ----- + + def self.from_h(dict) user = User.new user.username = dict['username'] user.attributes = dict['attributes'] user.extern = dict['extern'] user.backend = dict['backend'] + user.consent = dict['consent'] user.password = string_to_pass_hash(dict['password']) unless user.extern user end - def to_dict + def to_h { 'username' => username, 'attributes' => attributes, 'password' => password&.to_s, 'extern' => extern, - 'backend' => backend + 'backend' => backend, + 'consent' => consent }.compact end - def self.delete_user(username) - !PluginLoader.load_plugins('user_db').index { |db| db.delete_user(username) }.nil? + # ----- Whether the user has such an attribute ----- + + def claim?(searchkey, searchvalue = nil) + attribute = attributes.select { |a| a['key'] == searchkey }.first + !attribute.nil? && (searchvalue.nil? || attribute['value'] == searchvalue) end - def self.add_user(user, user_backend) - PluginLoader.load_plugin('user_db', user_backend).create_user(user) + # ----- Util ----- + + # usernames are the primary key for users + def ==(other) + username == other.username end - def save - PluginLoader.load_plugin('user_db', backend || Config.base_config['user_backend_default']).update_user(self) + def self.string_to_pass_hash(str) + if BCrypt::Password.valid_hash? str + BCrypt::Password.new str + else + BCrypt::Password.create str + end end +end - def update_password(new_password) - PluginLoader.load_plugin('user_db', backend).update_password(self, User.string_to_pass_hash(new_password)) +# The default User DB saves User Configuration in a dedicated configuration section +class DefaultUserDB + def self.create_user(bind) + user = bind.local_variable_get('user') + return unless user.backend == 'yaml' + + users = get_all bind + users << user + Config.user_config = users.map(&:to_h) end - def self.generate_extern_user(provider, json) - return nil if json[provider['external_userid']].nil? + def self.delete_user(bind) + user = get bind + return unless user - username = json[provider['external_userid']] - user = User.find_by_id(username) - return user unless user.nil? + users = get_all bind + users.delete(user) + Config.user_config = users.map(&:to_h) + end - user = User.new - user.username = username - user.extern = provider['name'] || false - user.attributes = [*provider['claim_mapper']].map do |mapper| - PluginLoader.load_plugin('claim_mapper', mapper).map_from_provider(json, provider) - end.flatten(1) - User.add_user(user, Config.base_config['user_backend_default']) - user + def self.update_user(bind) + user = bind.local_variable_get('user') + return unless user.backend == 'yaml' + + users = get_all bind + idx = users.index user + return false unless idx + + users[idx] = user + Config.user_config = users.map(&:to_h) + true end - def claim?(searchkey, searchvalue = nil) - attribute = attributes.select { |a| a['key'] == searchkey }.first - !attribute.nil? && (searchvalue.nil? || attribute['value'] == searchvalue) + def self.get_all(_bind) + Config.user_config.map do |user| + user['backend'] = 'yaml' + User.from_h user + end end - # usernames are unique - def ==(other) - username == other.username + def self.update_password(bind) + user = bind.local_variable_get('user') + password = bind.local_variable_get('password') + return unless user.backend == 'yaml' + + user.password = User.string_to_pass_hash password + update_user(bind) end - def self.string_to_pass_hash(str) - if BCrypt::Password.valid_hash? str - BCrypt::Password.new str - else - BCrypt::Password.create str + def self.verify_password(bind) + user = bind.local_variable_get('user') + password = bind.local_variable_get('password') + return unless user.backend == 'yaml' + + user.password == password + end + + def self.get(bind) + username = bind.local_variable_get 'username' + Config.user_config.each do |user| + next unless user['username'] == username + + user['backend'] = 'yaml' + return User.from_h user end + nil + end + + # register functions + def self.register + PluginLoader.register 'USER_GET', method(:get) + PluginLoader.register 'USER_GET_ALL', method(:get_all) + PluginLoader.register 'USER_CREATE', method(:create_user) + PluginLoader.register 'USER_UPDATE', method(:update_user) + PluginLoader.register 'USER_DELETE', method(:delete_user) + PluginLoader.register 'USER_AUTHENTICATION_PASSWORD_CHANGE', method(:update_password) + PluginLoader.register 'USER_AUTHENTICATION_PASSWORD_VERIFY', method(:verify_password) end end diff --git a/omejdn.rb b/omejdn.rb index 03b6b08..75577d7 100644 --- a/omejdn.rb +++ b/omejdn.rb @@ -29,9 +29,7 @@ # A global cache, capable of storing data between calls and sessions class Cache - class << self; attr_accessor :user_session, :authorization, :par, :public_endpoints end - # Stores User Sessions. We probably want to use a KV-store at some point - @user_session = {} + class << self; attr_accessor :authorization, :par, :public_endpoints end # Stores Authorization Code Metadata inbetween authorization and token retrieval # Contains: user, nonce, scopes, resources, claims, pkce challenge and method @authorization = {} @@ -41,7 +39,21 @@ class << self; attr_accessor :user_session, :authorization, :par, :public_endpoi @public_endpoints = [] end +# Define endpoints using this to support fine-grained CORS +def endpoint(endpoint, methods, public_endpoint: false, &block) + Cache.public_endpoints << (Regexp.new endpoint) if public_endpoint + [*methods].each do |verb| + get endpoint, {}, &block if verb == 'GET' # Takes care of 'HEAD' + post endpoint, {}, &block if verb == 'POST' + put endpoint, {}, &block if verb == 'PUT' + delete endpoint, {}, &block if verb == 'DELETE' + end +end + configure do + # Load Plugins + PluginLoader.initialize + config = Config.setup set :environment, (proc { Config.base_config['environment'].to_sym }) enable :dump_errors, :raise_errors, :quiet @@ -54,15 +66,12 @@ class << self; attr_accessor :user_session, :authorization, :par, :public_endpoi set :session_store, Rack::Session::Pool end -# Define endpoints using this to support fine-grained CORS -def endpoint(endpoint, methods, public_endpoint: false, &block) - Cache.public_endpoints << (Regexp.new endpoint) if public_endpoint - [*methods].each do |verb| - get endpoint, {}, &block if verb == 'GET' # Takes care of 'HEAD' - post endpoint, {}, &block if verb == 'POST' - put endpoint, {}, &block if verb == 'PUT' - delete endpoint, {}, &block if verb == 'DELETE' - end +def debug + Config.base_config['environment'] != 'production' +end + +def openid?(scopes) + Config.base_config['openid'] && (scopes.include? 'openid') end before do @@ -89,14 +98,6 @@ def endpoint(endpoint, methods, public_endpoint: false, &block) end end -def debug - Config.base_config['environment'] != 'production' -end - -def openid?(scopes) - Config.base_config['openid'] && (scopes.include? 'openid') -end - ########## TOKEN ISSUANCE ################## # Handle token request @@ -105,10 +106,11 @@ def openid?(scopes) client = OAuthHelper.authenticate_client params, env.fetch('HTTP_AUTHORIZATION', '') raise OAuthError.new 'invalid_request', 'Grant type not allowed' unless client.grant_type_allowed? params[:grant_type] + PluginLoader.fire('TOKEN_STARTED', binding) case params[:grant_type] when 'client_credentials' scopes = filter_scopes(client, client.filter_scopes(params[:scope]&.split) || []) - resources = [Config.base_config['default_audience']] if resources.empty? + resources = [*Config.base_config['default_audience']] if resources.empty? req_claims = JSON.parse(params[:claims] || '{}') raise OAuthError.new 'invalid_target', "Access denied to: #{resources}" unless client.resources_allowed? resources when 'authorization_code' @@ -122,7 +124,9 @@ def openid?(scopes) raise OAuthError.new 'invalid_target', "No access to: #{resources}" unless (resources - cache[:resource]).empty? raise OAuthError, 'invalid_request' if cache[:redirect_uri] && cache[:redirect_uri] != params[:redirect_uri] else - raise OAuthError.new 'unsupported_grant_type', "Given: #{params[:grant_type]}" + custom_grant_type = false + PluginLoader.fire('TOKEN_UNKNOWN_GRANT_TYPE', binding) + raise OAuthError.new 'unsupported_grant_type', "Given: #{params[:grant_type]}" unless custom_grant_type end raise OAuthError.new 'access_denied', 'No scopes granted' if scopes.empty? @@ -136,56 +140,53 @@ def openid?(scopes) nonce = cache&.dig(:nonce) id_token = Token.id_token client, user, scopes, req_claims, nonce if openid?(scopes) access_token = Token.access_token client, user, scopes, req_claims, resources - # Delete the authorization code as it is single use - Cache.authorization.delete(params[:code]) - halt 200, { 'Content-Type' => 'application/json' }, { + response = { access_token: access_token, id_token: id_token, expires_in: Config.base_config.dig('access_token', 'expiration'), token_type: 'bearer', scope: (scopes.join ' ') - }.compact.to_json + } + PluginLoader.fire('TOKEN_FINISHED', binding) + # Delete the authorization code as it is single use + Cache.authorization.delete(params[:code]) + halt 200, { 'Content-Type' => 'application/json' }, response.compact.to_json rescue OAuthError => e halt 400, { 'Content-Type' => 'application/json' }, e.to_s end -########## AUTHORIZATION FLOW ################## +########## AUTHORIZATION CODE FLOW ################## -# Defines tasks for the user before a code is issued -module AuthorizationTask - ACCOUNT_SELECT = 1 - LOGIN = 2 - CONSENT = 3 - ISSUE = 4 +def auth_response(auth, response_params) + auth ||= {} + response_params = { + iss: Config.base_config['issuer'], + state: auth[:state] + }.merge(response_params).compact + PluginLoader.fire('AUTHORIZATION_FINISHED', binding) + halt 400, (haml :error, locals: { error: response_params }) if auth[:redirect_uri].nil? + case auth[:response_mode] + when 'form_post' + halt 200, (haml :form_post_response, locals: { redirect_uri: auth[:redirect_uri], params: response_params }) + when 'fragment' + redirect to("#{auth[:redirect_uri]}##{URI.encode_www_form response_params}") + else # 'query' and unsupported types + redirect to("#{auth[:redirect_uri]}?#{URI.encode_www_form response_params}") + end end -# Redirect to the current task. -# completed_task will be removed from the list -def next_task(completed_task = nil) - auth = Cache.authorization[session[:current_auth]] - tasklist = auth[:tasks] - tasklist ||= [] - tasklist.delete(completed_task) unless completed_task.nil? - tasklist.sort!.uniq! - case tasklist.first - when AuthorizationTask::ACCOUNT_SELECT - # FIXME: Provide a way to choose the current account without requiring another login - tasklist[0] = AuthorizationTask::LOGIN - tasklist.uniq! - next_task - when AuthorizationTask::LOGIN - redirect to("#{Config.base_config['front_url']}/login") - when AuthorizationTask::CONSENT - redirect to("#{Config.base_config['front_url']}/consent") - when AuthorizationTask::ISSUE - # Only issue code once - tasklist.shift - auth_response auth, { code: session[:current_auth] } +def filter_scopes(resource_owner, scopes) + scope_mapping = Config.scope_mapping_config + scopes.select do |s| + if s == 'openid' + true + elsif s.include? ':' + key, value = s.split(':', 2) + resource_owner.claim?(key, value) + else + (scope_mapping[s] || []).any? { |claim| resource_owner.claim?(claim) } + end 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("#{Config.base_config['front_url']}/login") end # Pushed Authorization Requests @@ -197,6 +198,7 @@ def next_task(completed_task = nil) uri = "urn:ietf:params:oauth:request_uri:#{SecureRandom.uuid}" Cache.par[uri] = params # TODO: Expiration + PluginLoader.fire('AUTHORIZATION_PAR', binding) halt 201, { 'Content-Type' => 'application/json' }, { 'request_uri' => uri, 'expires_in' => 60 }.to_json rescue OAuthError => e halt 400, { 'Content-Type' => 'application/json' }, e.to_s @@ -210,120 +212,79 @@ def next_task(completed_task = nil) # Generate new authorization code and aggregate data about the request # Any inputs to members not starting in req_ are sufficiently sanitized + # Note that some values are reassigned after dealing with request and request_uri session[:current_auth] = SecureRandom.uuid - Cache.authorization[session[:current_auth]] = cache = { - client_id: client.client_id, # The requesting client + Cache.authorization[session[:current_auth]] = auth = { + client: client, # The requesting client state: params[:state], # Client state nonce: params[:nonce], # The client's OIDC nonce - response_mode: params[:response_mode], # The response mode to use - tasks: [] # Tasks the user has to perform + response_mode: params[:response_mode] # The response mode to use } # Used for error messages, might be overwritten by request objects - cache[:redirect_uri] = client.verify_redirect_uri params[:redirect_uri], true if params[:redirect_uri] + auth[:redirect_uri] = client.verify_redirect_uri params[:redirect_uri], true if params[:redirect_uri] OAuthHelper.prepare_params params, client uri = client.verify_redirect_uri params[:redirect_uri], openid?((params[:scope] || '').split) # For real this time + # Some of these values may have been overwritten + auth.merge!({ + state: params[:state], # Client state + nonce: params[:nonce], # The client's OIDC nonce + response_mode: params[:response_mode] # The response mode to use + }) + raise OAuthError.new 'invalid_scope', 'No scope specified' unless params[:scope] # We require specifying the scope raise OAuthError.new 'unsupported_response_type', 'Only code supported' unless params[:response_type] == 'code' if !params[:code_challenge].nil? && params[:code_challenge_method] != 'S256' raise OAuthError.new 'invalid_request', 'Transform algorithm not supported' end - cache.merge!({ - redirect_uri: uri, - pkce: params[:code_challenge], - pkce_method: params[:code_challenge_method], - req_scope: params[:scope].split, - req_claims: JSON.parse(params[:claims] || '{}'), - req_resource: params['resource'] - }) - - # We first define a minimum set of acceptable tasks - if session[:user].nil? # User not yet logged in - cache[:tasks] << AuthorizationTask::LOGIN - cache[:tasks] << AuthorizationTask::CONSENT - else - user = Cache.user_session[session[:user]] - update_auth_scope cache, user, client - # If consent is not yet given to the client, demand it - if (cache[:scope] - (session.dig(:consent, client.client_id) || [])).empty? - cache[:user] = user - else - cache[:tasks] << AuthorizationTask::CONSENT - end - end - - # 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' - raise OAuthError, 'account_selection_required' if cache[:tasks].include? AuthorizationTask::ACCOUNT_SELECT - raise OAuthError, 'login_required' if cache[:tasks].include? AuthorizationTask::LOGIN - raise OAuthError, 'consent_required' if cache[:tasks].include? AuthorizationTask::CONSENT - raise OAuthError.new 'invalid_request', "Invalid 'prompt' values: #{params[:prompt]}" if params[:prompt] != 'none' - when 'login' - cache[:tasks] << AuthorizationTask::LOGIN - when 'consent' - cache[:tasks] << AuthorizationTask::CONSENT - when 'select_account' - cache[:tasks] << AuthorizationTask::ACCOUNT_SELECT - end - end - if params[:max_age] && session[:user] && - (Time.new.to_i - Cache.user_session[session[:user]].auth_time) > params[:max_age].to_i - cache[:tasks] << AuthorizationTask::LOGIN + auth.merge!({ + redirect_uri: uri, + pkce: params[:code_challenge], + pkce_method: params[:code_challenge_method], + req_scope: params[:scope].split, + req_claims: JSON.parse(params[:claims] || '{}'), + req_resource: params['resource'] + }) + + auth[:req_max_age] = params[:max_age] + auth[:req_tasks] = params[:prompt]&.split&.uniq || [] + if (auth[:req_tasks].include? 'none') && auth[:req_tasks] != ['none'] + raise OAuthError.new 'invalid_request', "Invalid 'prompt' values: #{params[:prompt]}" end - # Redirect the user to start the authentication flow - cache[:tasks] << AuthorizationTask::ISSUE - next_task + PluginLoader.fire('AUTHORIZATION_STARTED', binding) + redirect to("#{Config.base_config['front_url']}/login") rescue OAuthError => e auth_response Cache.authorization[session[:current_auth]], e.to_h end -def filter_scopes(resource_owner, scopes) - scope_mapping = Config.scope_mapping_config - scopes.select do |s| - if s == 'openid' - true - elsif s.include? ':' - key, value = s.split(':', 2) - resource_owner.claim?(key, value) - else - (scope_mapping[s] || []).any? { |claim| resource_owner.claim?(claim) } - end - end -end +########## CONSENT ################## + +endpoint '/consent', ['GET'] do + auth = Cache.authorization[session[:current_auth]] + + redirect to("#{Config.base_config['front_url']}/login") if (user = auth[:user]).nil? # Require Login for this step + raise OAuthError.new 'invalid_client', 'Client unknown' if (client = auth[:client]).nil? -def update_auth_scope(auth, user, client) # Find the right scopes auth[:scope] = filter_scopes(user, client.filter_scopes(auth[:req_scope])) - p "Granted scopes: #{auth[:scope]}" - p "The user seems to be #{user.username}" if debug - auth[:claims] = auth[:req_claims] auth[:resource] = [auth[:req_resource] || Config.base_config['default_audience']].flatten raise OAuthError.new 'invalid_target', 'Resources not granted' unless client.resources_allowed? auth[:resource] -end - -endpoint '/consent', ['GET'] do - auth = Cache.authorization[session[:current_auth]] - if session[:user].nil? - auth[:tasks].unshift AuthorizationTask::LOGIN - next_task - end - user = Cache.user_session[session[:user]] - raise OAuthError.new 'invalid_user', 'User session invalid' if user.nil? + p "Granted scopes: #{auth[:scope]}" + p "The user seems to be #{user.username}" if debug - client = Client.find_by_id auth[:client_id] - raise OAuthError.new 'invalid_client', 'Client unknown' if client.nil? + # Is consent required? + consent_required = auth[:req_tasks].include? 'consent' + consent_required ||= !(auth[:scope] - (user.consent&.dig(client.client_id) || [])).empty? + auth_response auth, { code: session[:current_auth] } unless consent_required # Shortcut + raise OAuthError, 'consent_required' if auth[:req_tasks].include? 'none' - # Seems to be in order - return haml :authorization_page, locals: { + PluginLoader.fire('AUTHORIZATION_CONSENT_STARTED', binding) + return haml :consent, locals: { host: Config.base_config['front_url'], user: user, client: client, @@ -336,58 +297,24 @@ def update_auth_scope(auth, user, client) endpoint '/consent/exec', ['POST'] do auth = Cache.authorization[session[:current_auth]] - session[:consent] ||= {} - session[:consent][auth[:client_id]] = auth[:scope] - auth[:user] = Cache.user_session[session[:user]] - next_task AuthorizationTask::CONSENT + redirect to("#{Config.base_config['front_url']}/login") if (user = auth[:user]).nil? # Require Login for this step + (user.consent ||= {})[auth[:client].client_id] = auth[:scope] + PluginLoader.fire('AUTHORIZATION_CONSENT_FINISHED', binding) + user.save + auth_response auth, { code: session[:current_auth] } rescue OAuthError => e auth_response Cache.authorization[session[:current_auth]], e.to_h end -def auth_response(auth, response_params) - auth ||= {} - response_params = { - iss: Config.base_config['issuer'], - state: auth[:state] - }.merge(response_params).compact - halt 400, response_params.to_json if auth[:redirect_uri].nil? - case auth[:response_mode] - when 'form_post' - halt 200, (haml :form_post_response, locals: { redirect_uri: auth[:redirect_uri], params: response_params }) - when 'fragment' - redirect to("#{auth[:redirect_uri]}##{URI.encode_www_form response_params}") - else # 'query' and unsupported types - redirect to("#{auth[:redirect_uri]}?#{URI.encode_www_form response_params}") - end -end - -########## USERINFO ################## - -endpoint '/userinfo', ['GET', 'POST'], public_endpoint: true do - token = Token.decode env.fetch('HTTP_AUTHORIZATION', '')&.slice(7..-1), '/userinfo' - client = Client.find_by_id token['client_id'] - user = User.find_by_id(token['sub']) - halt 401 if user.nil? - req_claims = token.dig('omejdn_reserved', 'userinfo_req_claims') - userinfo = OAuthHelper.map_claims_to_userinfo(user.attributes, req_claims, client, token['scope'].split) - userinfo['sub'] = user.username - halt 200, { 'Content-Type' => 'application/json' }, userinfo.to_json -rescue StandardError => e - p e if debug - halt 401 -end - ########## LOGIN/LOGOUT ################## # OpenID Connect RP-Initiated Logout 1.0 endpoint '/logout', ['GET', 'POST'], public_endpoint: true do id_token = Token.decode params[:id_token_hint] client = Client.find_by_id id_token&.dig('aud') - halt 200, (haml :logout, locals: { - state: params[:state], - redirect_uri: (client&.verify_post_logout_redirect_uri params[:post_logout_redirect_uri]), - user: ((User.find_by_id id_token&.dig('sub')) || Cache.user_session[session[:user]]) - }) + redirect_uri = client&.verify_post_logout_redirect_uri params[:post_logout_redirect_uri] + PluginLoader.fire('LOGOUT_STARTED', binding) + halt 200, (haml :logout, locals: { state: params[:state], redirect_uri: redirect_uri }) rescue StandardError halt 400 end @@ -396,81 +323,64 @@ def auth_response(auth, response_params) session.delete(:user) # TODO: log out the specified user only redirect_uri = "#{Config.base_config['front_url']}/login" redirect_uri = params[:redirect_uri] + (params[:state] || '') if params[:redirect_uri] + PluginLoader.fire('LOGOUT_FINISHED', binding) redirect to(redirect_uri) end -# FIXME -# This should use a more generic way to select the OP to use +# Call this function to end the login process +def login_finished(user, authenticated, remember_me: false) + auth = Cache.authorization[session[:current_auth]] + user.auth_time = Time.new.to_i if authenticated + PluginLoader.fire('AUTHORIZATION_LOGIN_FINISHED', binding) + user.save + auth[:user] = user + session[:user] = user.username if remember_me + redirect to("#{Config.base_config['front_url']}/consent") +end + endpoint '/login', ['GET'] do - providers = Config.oauth_provider_config&.map do |provider| - url = URI(provider['authorization_endpoint']) - url.query = URI.encode_www_form({ - client_id: provider['client_id'], - scope: provider['scopes'], - redirect_uri: provider['redirect_uri'], - response_type: provider['response_type'] - }) - { url: url.to_s, name: provider['name'], logo: provider['logo'] } - end + # Is login required? + auth = Cache.authorization[session[:current_auth]] + login_required = session[:user].nil? || !(%w[login select_account] & auth[:req_tasks]).empty? + login_required ||= (user = User.find_by_id session[:user]).nil? + login_required ||= auth[:req_max_age] && (Time.new.to_i - user.auth_time) > auth[:req_max_age].to_i + login_finished user, false unless login_required + raise OAuthError, 'login_required' if auth[:req_tasks].include? 'none' + + login_options = [] + PluginLoader.fire('AUTHORIZATION_LOGIN_STARTED', binding) halt 200, (haml :login, locals: { no_password_login: (Config.base_config['no_password_login'] || false), host: Config.base_config['front_url'], - providers: providers + login_options: login_options }) +rescue OAuthError => e + auth_response Cache.authorization[session[:current_auth]], e.to_h end endpoint '/login/exec', ['POST'] do - user = User.find_by_id(params[:username]) - unless user&.verify_password(params[:password]) - redirect to("#{Config.base_config['front_url']}/login?error=\"Credentials incorrect\"") - end - user.auth_time = Time.new.to_i - session[:user] = SecureRandom.uuid - Cache.user_session[session[:user]] = user - auth = Cache.authorization[session[:current_auth]] - auth[:user] = user - update_auth_scope auth, user, (Client.find_by_id auth[:client_id]) - next_task AuthorizationTask::LOGIN + user = User.find_by_id params[:username] + redirect to("#{Config.base_config['front_url']}/login?incorrect") unless user&.verify_password(params[:password]) + login_finished user, true, remember_me: true rescue OAuthError => e auth_response Cache.authorization[session[:current_auth]], e.to_h end -# FIXME -# This should also be more generic and use the correct OP -endpoint '/oauth_cb', ['GET'], public_endpoint: true do - oauth_providers = Config.oauth_provider_config - provider = oauth_providers.select { |pv| pv['name'] == params[:provider] }.first - - at = nil - 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' => params[:code], - 'client_id' => provider['client_id'], - 'client_secret' => provider['client_secret'], - 'grant_type' => 'authorization_code', - 'redirect_uri' => provider['redirect_uri']) - res = http.request req - at = JSON.parse(res.body)['access_token'] - end - return 'Unauthorized' if at.nil? - - user = nil - 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(provider, JSON.parse(res.body)) - end - return 'Internal Error' if user.username.nil? +########## USERINFO ################## - user.auth_time = Time.new.to_i - session[:user] = SecureRandom.uuid - Cache.user_session[session[:user]] = user - auth = Cache.authorization[session[:current_auth]] - update_auth_scope auth, user, (Client.find_by_id auth[:client_id]) - next_task AuthorizationTask::LOGIN +endpoint '/userinfo', ['GET', 'POST'], public_endpoint: true do + token = Token.decode env.fetch('HTTP_AUTHORIZATION', '')&.slice(7..-1), '/userinfo' + client = Client.find_by_id token['client_id'] + user = User.find_by_id token['sub'] + halt 401 unless user && client + req_claims = token.dig('omejdn_reserved', 'userinfo_req_claims') + userinfo = OAuthHelper.map_claims_to_userinfo(user.attributes, req_claims, client, token['scope'].split) + userinfo['sub'] = user.username + PluginLoader.fire('OPENID_USERINFO', binding) + halt 200, { 'Content-Type' => 'application/json' }, userinfo.to_json +rescue StandardError => e + p e if debug + halt 401 end ########## WELL-KNOWN ENDPOINTS ################## @@ -481,34 +391,38 @@ def auth_response(auth, response_params) end endpoint '/.well-known/(oauth-authorization-server|openid-configuration)', ['GET'], public_endpoint: true do - halt 200, { 'Content-Type' => 'application/json' }, OAuthHelper.configuration_metadata.to_json + metadata = OAuthHelper.configuration_metadata + PluginLoader.fire('STATIC_METADATA', binding) + halt 200, { 'Content-Type' => 'application/json' }, (OAuthHelper.sign_metadata metadata).to_json end endpoint '/.well-known/webfinger', ['GET'], public_endpoint: true do res = CGI.unescape((params[:resource] || '').gsub('%20', '+')) halt 400 unless res.start_with? 'acct:' halt 404 if Config.webfinger_config.filter { |h| res.end_with? h }.empty? - halt 200, { 'Content-Type' => 'application/json' }, { + webfinger = { subject: res, properties: {}, links: [{ rel: 'http://openid.net/specs/connect/1.0/issuer', href: Config.base_config['issuer'] }] - }.to_json + } + PluginLoader.fire('STATIC_WEBFINGER', binding) + halt 200, { 'Content-Type' => 'application/json' }, webfinger.to_json end endpoint '/jwks.json', ['GET'], public_endpoint: true do - halt 200, { 'Content-Type' => 'application/json' }, Keys.generate_jwks.to_json + jwks = Keys.generate_jwks + PluginLoader.fire('STATIC_JWKS', binding) + halt 200, { 'Content-Type' => 'application/json' }, jwks.to_json end endpoint '/about', ['GET'], public_endpoint: true do - halt 200, { 'Content-Type' => 'application/json' }, { - 'version' => OMEJDN_VERSION, - 'license' => OMEJDN_LICENSE - }.to_json + about = { 'version' => OMEJDN_VERSION, 'license' => OMEJDN_LICENSE } + PluginLoader.fire('STATIC_ABOUT', binding) + halt 200, { 'Content-Type' => 'application/json' }, about.to_json end -# Load all Plugins and optionally create admin -PluginLoader.initialize +# Optionally create admin Config.create_admin diff --git a/plugins/api/admin_v1.rb b/plugins/admin_api/admin_api.rb similarity index 80% rename from plugins/api/admin_v1.rb rename to plugins/admin_api/admin_api.rb index a2facf7..d5069e7 100644 --- a/plugins/api/admin_v1.rb +++ b/plugins/admin_api/admin_api.rb @@ -23,12 +23,12 @@ # Users endpoint '/api/v1/config/users', ['GET'], public_endpoint: true do - halt 200, JSON.generate(User.all_users.map(&:to_dict)) + halt 200, JSON.generate(User.all_users.map(&:to_h)) end endpoint '/api/v1/config/users', ['POST'], public_endpoint: true do json = JSON.parse request.body.read - user = User.from_dict(json) + user = User.from_h(json) User.add_user(user, json['userBackend'] || Config.base_config['user_backend_default']) halt 201 end @@ -36,13 +36,13 @@ endpoint '/api/v1/config/users/:username', ['GET'], public_endpoint: true do user = User.find_by_id params['username'] halt 404 if user.nil? - halt 200, user.to_dict.to_json + halt 200, user.to_h.to_json end endpoint '/api/v1/config/users/:username', ['PUT'], public_endpoint: true do user = User.find_by_id params['username'] halt 404 if user.nil? - updated_user = User.from_dict(JSON.parse(request.body.read)) + updated_user = User.from_h(JSON.parse(request.body.read)) updated_user.username = user.username updated_user.save halt 204 @@ -64,78 +64,64 @@ # Clients endpoint '/api/v1/config/clients', ['GET'], public_endpoint: true do - JSON.generate Config.client_config + halt 200, JSON.generate(Client.all_clients.map(&:to_h)) end endpoint '/api/v1/config/clients', ['PUT'], public_endpoint: true do - Config.client_config = JSON.parse(request.body.read).map do |c| - client = Client.new - client.apply_values(c) - client + Client.all_clients.each { |c| Client.delete_client c.client_id } + + JSON.parse(request.body.read).each do |c| + Client.add_client Client.from_h(c), 'default' end halt 204 end endpoint '/api/v1/config/clients', ['POST'], public_endpoint: true do - client = Client.from_dict(JSON.parse(request.body.read)) - clients = Client.load_clients - clients << client - Config.client_config = clients + client = JSON.parse(request.body.read) + Client.add_client client, 'default' halt 201 end endpoint '/api/v1/config/clients/:client_id', ['GET'], public_endpoint: true do client = Client.find_by_id params['client_id'] halt 404 if client.nil? - halt 200, client.to_dict.to_json + halt 200, client.to_h.to_json end endpoint '/api/v1/config/clients/:client_id', ['PUT'], public_endpoint: true do json = JSON.parse(request.body.read) - clients = Client.load_clients - clients.each do |stored_client| - next if stored_client.client_id != params['client_id'] - - stored_client.attributes = json.delete('attributes') unless json['attributes'].nil? - stored_client.metadata.merge!(json) - Config.client_config = clients - halt 204 - end - halt 404 + client = Client.find_by_id params['client_id'] + halt 404 unless client + + client.attributes = json.delete('attributes') unless json['attributes'].nil? + client.metadata.merge!(json) + client.save + halt 204 end endpoint '/api/v1/config/clients/:client_id', ['DELETE'], public_endpoint: true do - clients = Client.load_clients - clients.each do |stored_client| - next unless stored_client.client_id.eql?(params['client_id']) - - clients.delete(stored_client) - Config.client_config = clients - halt 204 - end - halt 404 + Client.delete_client params['client_id'] + halt 204 end # Client Keys endpoint '/api/v1/config/clients/:client_id/keys', ['GET'], public_endpoint: true do - client = Client.find_by_id params['client_id'] - halt 404 if client.nil? - certificate = client.certificate + certificate = Client.find_by_id(params['client_id'])&.certificate halt 404 if certificate.nil? - halt 200, JSON.generate({ 'certificate' => client.certificate.to_s }) + halt 200, JSON.generate({ 'certificate' => certificate.to_s }) end endpoint '/api/v1/config/clients/:client_id/keys', ['PUT'], public_endpoint: true do client = Client.find_by_id params['client_id'] halt 404 if client.nil? - client.certificate = JSON.parse(request.body.read)['certificate'] + client.certificate = OpenSSL::X509::Certificate.new JSON.parse(request.body.read)['certificate'] halt 204 end endpoint '/api/v1/config/clients/:client_id/keys', ['POST'], public_endpoint: true do client = Client.find_by_id params['client_id'] halt 404 if client.nil? - client.certificate = JSON.parse(request.body.read)['certificate'] + client.certificate = OpenSSL::X509::Certificate.new JSON.parse(request.body.read)['certificate'] halt 201 end diff --git a/plugins/claim_mapper/_abstract.rb b/plugins/claim_mapper/_abstract.rb deleted file mode 100644 index 56a0947..0000000 --- a/plugins/claim_mapper/_abstract.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -# Abstract ClaimMapper interface -class ClaimMapper - # Used to map additional claims into the access token - def map_to_access_token(_client, _user, _scopes, _requested_claims, _resources) - {} - end - - # Used to map additional claims into the id token - def map_to_id_token(_client, _user, _scopes, _requested_claims) - {} - end - - # Used to generate external users - def map_from_provider(claims, provider) - raise NotImplementedError - end -end diff --git a/plugins/claim_mapper/attribute.rb b/plugins/claim_mapper/attribute.rb deleted file mode 100644 index 74850cd..0000000 --- a/plugins/claim_mapper/attribute.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require_relative './_abstract' -require_relative '../../lib/oauth_helper' - -# Maps Userinfo Claims to the Access- and ID tokens -# Includes any specifically requested claims as-is -class AttributeClaimMapper < ClaimMapper - attr_reader :config - - def initialize(config) - super() - @config = config || {} - end - - def map_to_access_token(client, user, scopes, requested_claims, _resources) - return {} if @config['skip_access_token'] - - OAuthHelper.map_claims_to_userinfo (user || client).attributes, requested_claims, client, scopes - end - - def map_to_id_token(client, user, scopes, requested_claims) - return {} if @config['skip_id_token'] - - OAuthHelper.map_claims_to_userinfo user.attributes, requested_claims, client, scopes - end -end - -# Monkey patch the loader -class PluginLoader - def self.load_claim_mapper_attribute(config) - AttributeClaimMapper.new config - end -end diff --git a/plugins/federation/default_attribute_mappers.rb b/plugins/federation/default_attribute_mappers.rb new file mode 100644 index 0000000..5a244c3 --- /dev/null +++ b/plugins/federation/default_attribute_mappers.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +def map_attributes_static(bind) + config = bind.local_variable_get 'mapper' + config['attributes'] +end +PluginLoader.register 'PLUGIN_FEDERATION_ATTRIBUTE_MAPPING_STATIC', method(:map_attributes_static) + +def map_attributes_clone(bind) + config = bind.local_variable_get 'mapper' + userinfo = bind.local_variable_get 'userinfo' + attrs = (config['mapping'] || {}).map do |map| + { + 'key' => map['to'], + 'value' => userinfo[map['from']] + } + end + attrs.reject { |a| a['key'].nil? } +end +PluginLoader.register 'PLUGIN_FEDERATION_ATTRIBUTE_MAPPING_CLONE', method(:map_attributes_clone) diff --git a/plugins/federation/federation.rb b/plugins/federation/federation.rb new file mode 100644 index 0000000..5d5dce5 --- /dev/null +++ b/plugins/federation/federation.rb @@ -0,0 +1,376 @@ +# frozen_string_literal: true + +# Plugin to act as an OAuth/OpenID client to federate the login process +# Will only support the authorization code grant type for normal OPs +# Will be extended to support SIOPv2 using the implicit flow + +# Goals (Authorization Code): +# + Support most of Omejdn's features +# + Support Server Metadata +# + Support Client Authentication methods none, client_secret_basic, client_secret_post, private_key_jwt +# + private_key_jwt uses our jwks_uri +# + Support Server Issuer Identification +# + Always use PKCE if possible +# + Support Pushed Authorization Requests +# + Optionally use nonce for OpenID requests +# + Cache Server Metadata +# + Configurable Claim Mapping + +# Goals (SIOPv2): +# - Support Implicit Flow +# - Support Authorization Code Flow +# - Support Same-Device SIOP +# - Support Cross-Device SIOP +# - Support Dynamic Discovery +# - Support Registration +# - Validate Response correctly + +require 'net/http' +require 'rqrcode' +require 'sinatra/streaming' +require_relative './default_attribute_mappers' + +# SIOPv2 Static Discovery Metadata +SIOP_V2_STATIC_METADATA = { + 'authorization_endpoint' => 'openid:', + 'issuer' => 'https://self-issued.me/v2', + 'response_types_supported' => ['id_token'], + 'scopes_supported' => ['openid'], + 'subject_types_supported' => ['pairwise'], + 'id_token_signing_alg_values_supported' => ['ES256'], + 'request_object_signing_alg_values_supported' => ['ES256'], + 'subject_syntax_types_supported' => ['urn:ietf:params:oauth:jwk-thumbprint'], + 'id_token_types_supported' => ['subject_signed'] +}.freeze + +def siop?(provider) + provider['self-issued'] || false +end + +def provider_config + PluginLoader.configuration('federation')&.dig('providers') +end + +def attribute_mapper_config + PluginLoader.configuration('federation')&.dig('attribute_mappers') +end + +def get_login_options(bind) + login_options = bind.local_variable_get('login_options') + provider_config.each do |id, options| + login_options << { + url: "#{Config.base_config['front_url']}/federation/#{id}", + desc: (options['description'] || "Login with #{id.capitalize}"), + logo: options['op_logo_uri'] + }.compact + end +end +PluginLoader.register 'AUTHORIZATION_LOGIN_STARTED', method(:get_login_options) + +# Remembers any sent requests +class FederationCache + class << self; attr_accessor :cache end + @cache = {} # indexed by the state parameter +end + +# Caches HTTP responses +class UrlCache + class << self; attr_accessor :cache end + @cache = {} # contains hashes with expiry and body + + def self.has?(url) + cached = @cache[url] + cached = nil if cached && cached[:expiry] < Time.now.to_i + !cached.nil? + end + + def self.get(url, force_reload: false) + return @cache.dig(url, :body) if has?(url) && !force_reload + + # Call the resource + res = Net::HTTP.get_response(URI(url)) + return nil unless res.is_a?(Net::HTTPSuccess) + + if (cache_control_header = res['Cache-Control']) + instructions = {} + cache_control_header.split(',').each do |cc| + key, value = cc.strip.split('=', 2) + instructions[key] = value || true + end + + if (exp = instructions['max-age']&.to_i) # TODO: Respect all other options + @cache[url] = { + expiry: Time.now.to_i + exp, + body: res.body + } + end + end + res.body + end +end + +def get_metadata(provider) + return (siop?(provider) ? SIOP_V2_STATIC_METADATA : nil) unless provider['issuer'] + + issuer = URI(provider['issuer']) + metadata_locations = [ + "#{issuer.scheme}://#{issuer.host}:#{issuer.port}/.well-known/oauth-authorization-server#{issuer.path}", # RFC 8414 + "#{issuer.scheme}://#{issuer.host}:#{issuer.port}/.well-known/openid-configuration#{issuer.path}", # RFC 8414 Legacy + "#{issuer.scheme}://#{issuer.host}:#{issuer.port}#{issuer.path}/.well-known/openid-configuration" # OIDC Discovery + ] + + if (cached = metadata_locations.filter { |url| UrlCache.has? url }.first) + metadata = UrlCache.get(cached) + else + metadata_locations.each { |url| metadata ||= UrlCache.get url } + end + + metadata = JSON.parse(metadata) + metadata['issuer'] == provider['issuer'] ? metadata : nil +end + +# A request employing client authentication +def authenticated_post(provider, target, params) + case provider['token_endpoint_auth_method'] + when 'none' + params[:client_id] = provider['client_id'] + when 'client_secret_basic' + http_auth = "Basic #{Base64.strict_encode64("#{provider['client_id']}:#{provider['client_secret']}")}".chomp + when 'client_secret_post' + params[:client_id] = provider['client_id'] + params[:client_secret] = provider['client_secret'] + when 'private_key_jwt' + now = Time.now.to_i + json = { + iss: provider['client_id'], + sub: provider['client_id'], + aud: provider['issuer'], # Does not support all SIOPs + exp: now + 60, + nbf: now, + iat: now, + jti: SecureRandom.uuid + } + key_pair = Keys.load_key KEYS_TARGET_OMEJDN, 'omejdn' + params[:client_assertion_type] = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + params[:client_assertion] = JWT.encode json, key_pair['sk'], 'RS256', { typ: 'JWT', kid: key_pair['kid'] } + end + + p params + post target, params, http_auth +end + +def post(target, params, auth_header = nil) + target = URI(target) + req = Net::HTTP::Post.new(target) + req['Authorization'] = auth_header if auth_header + req.set_form_data params if params + res = Net::HTTP.start(target.hostname, target.port, use_ssl: target.scheme == 'https') do |http| + http.request(req) + end + res.body +end + +before '/federation/:provider_id*' do + halt 404 unless (@provider = provider_config[params['provider_id']]) + # Try to obtain metadata + @metadata = @provider['metadata'] || (get_metadata @provider) + halt 401 unless @metadata +end + +def check_prerequisites(mapper, userinfo) + !((mapper['prerequisites'] || {}).any? do |k, v| + ([*userinfo[k]] & [*v]).empty? + end) +end + +def generate_extern_user(provider, userinfo) + base_config = Config.base_config + username = "#{userinfo['sub']}@#{provider['issuer']}" # Maintain unique usernames + user = User.find_by_id(username) + + # Add user if they is logging in for the first time + if user.nil? + user = User.new + user.username = username + user.extern = provider['issuer'] || true + user.backend = base_config['user_backend_default'] + User.add_user(user, base_config['user_backend_default']) + end + + # Update local Attributes + user.attributes = (provider['attribute_mappers'] || []).map do |mapper| + mapper = attribute_mapper_config&.dig(mapper) + if check_prerequisites mapper, userinfo + PluginLoader.fire "PLUGIN_FEDERATION_ATTRIBUTE_MAPPING_#{mapper['type'].upcase}", binding + end + end.flatten(2).compact + user.save + + user +end + +def implicit?(provider, metadata) + siop?(provider) && !metadata['response_types_supported'].include?('code') +end + +def jwk_thumbprint(jwk) + jwk = jwk.clone + jwk.delete(:kid) + digest = Digest::SHA256.new + digest << jwk.sort.to_h.to_json + digest.base64digest.gsub('+', '-').gsub('/', '_').gsub('=', '') +end + +# Redirect the user here to start the flow +endpoint '/federation/:provider_id', ['GET'] do + code_verifier = SecureRandom.uuid + oauth_params = { + response_type: 'code', + redirect_uri: "#{Config.base_config['front_url']}/federation/#{params['provider_id']}/callback", + scope: [*@provider['scope']].join(' '), + nonce: SecureRandom.uuid, + code_challenge_method: 'S256', + code_challenge: OAuthHelper.generate_pkce(code_verifier, 'S256'), + state: SecureRandom.uuid + } + if implicit? @provider, @metadata + # Use Implicit Flow for SIOP if necessary + oauth_params[:response_type] = 'id_token' + oauth_params[:response_mode] = 'query' + oauth_params.delete(:code_challenge_method) + oauth_params.delete(:code_challenge) + end + + FederationCache.cache[oauth_params[:state]] = { + issuer: @provider['issuer'], + nonce: oauth_params[:nonce], + code_verifier: code_verifier, + current_auth: session[:current_auth] + } + + # Pushed Authorization Requests where possible + request_params = { client_id: @provider['client_id'] } + if siop?(@provider) && @provider['client_id'].nil? + request_params[:client_id] ||= oauth_params[:redirect_uri] + # Registration + registration_params = { + 'subject_syntax_types_supported' => ['urn:ietf:params:oauth:jwk-thumbprint'], + 'id_token_signing_alg_values_supported' => %w[RS256 RS512 ES256 ES512] + } + if @metadata['registration_endpoint'] + halt 400, 'Dynamic Client Registration is not implemented' + else + oauth_params[:registration] = registration_params.to_json + end + end + + # TODO: Signing using OIDC Federation? + if @metadata['pushed_authorization_request_endpoint'] + request_uri = authenticated_post(@provider, @metadata['pushed_authorization_request_endpoint'], oauth_params) + request_params['request_uri'] = (JSON.parse request_uri)['request_uri'] + halt 400, "PAR failed: #{request_uri}" unless request_params['request_uri'] + else + request_params.merge! oauth_params + end + + # Start Authorization Flow + request_url = "#{@metadata['authorization_endpoint']}?#{URI.encode_www_form request_params}" + if siop?(@provider) + request_params[:response_mode] = 'post' + cross_device_request_url = "#{@metadata['authorization_endpoint']}?#{URI.encode_www_form request_params}" + siop_haml = File.read 'plugins/federation/federation_siop.haml' + halt 200, (haml siop_haml, locals: { + state: oauth_params[:state], + provider_id: params['provider_id'], + href: request_url, + cross_device_href: cross_device_request_url, + qr: RQRCode::QRCode.new(cross_device_request_url).as_svg(module_size: 4) + }) + else + redirect to(request_url) + end +end + +# This endpoint is for notifying the open browser window +# when login is done on another device (see SIOP Cross-Device Callback below) +get '/federation/:provider_id/stream', provides: 'text/event-stream' do + halt 400, 'Cache' unless params['state'] && (cached = FederationCache.cache[params['state']]) + stream :keep_open do |out| + cached[:callback_stream] = out + out.callback { cached.delete(:callback_stream) } + end +end + +# SIOP Cross-Device Callback +# We just signal to the open browser window to complete the callback +# at the normal callback endpoint below +endpoint '/federation/:provider_id/callback', ['POST'] do + halt 400, 'Cache' unless params['state'] && (cached = FederationCache.cache[params['state']]) + halt 400, 'Stream not available' unless (out = cached[:callback_stream]) + params.delete('provider_id') + out << "data: #{URI.encode_www_form params}\n\n" + out.flush +end + +# Callback endpoint +endpoint '/federation/:provider_id/callback', ['GET'] do + halt 400, 'Cache' unless params['state'] && (cached = FederationCache.cache.delete(params['state'])) + + # Authorization Server Issuer Identification (RFC 9207) + halt 400, 'ISS' if @metadata['authorization_response_iss_parameter_supported'] && params['iss'] != cached[:issuer] + + # Restore cached auth context handler + session[:current_auth] = cached[:current_auth] + + # Error handling + if params['error'] + halt 400, + "Error: The Federation partner responded with #{params['error']}: #{params['error_description']}" + end + + # Get id_token and userinfo + if implicit? @provider, @metadata + halt 400, 'No ID Token' unless (id_token = params['id_token']) + userinfo, = JWT.decode id_token, nil, false + else + # Get Access Token + token_params = { + grant_type: 'authorization_code', + code: params['code'], + redirect_uri: "#{Config.base_config['front_url']}/federation/#{params['provider_id']}/callback", + code_verifier: cached[:code_verifier] + } + token_response = (JSON.parse authenticated_post(@provider, @metadata['token_endpoint'], token_params)) + halt 400, 'No access Token' unless (access_token = token_response['access_token']) + halt 400, 'No ID Token' unless (id_token = token_response['id_token']) + + # Get Userinfo + userinfo = post(@metadata['userinfo_endpoint'], nil, "Bearer #{access_token}") + userinfo = JSON.parse(userinfo) + end + + # Verify ID Token + if siop? @provider + # Since we know already that we are looking for a SIOP id_token, we deviate slightly from the draft + # and throw errors whenever something goes bad. + # Verify signature + id_token, = JWT.decode id_token, nil, true, { algorithms: %w[RS256 RS512 ES256 ES512] } do |_header, body| + # We only support JWK-Thumbprints atm. + JWT::JWK.import(body['sub_jwk']).keypair.public_key + end + # Verify self-signedness + halt 400, 'wrong sub' if id_token['sub'] != jwk_thumbprint(id_token['sub_jwk']) + halt 400, 'not self-issued' if id_token['iss'] != id_token['sub'] + client_id = "#{Config.base_config['front_url']}/federation/#{params['provider_id']}/callback" + halt 400, 'wrong audience' if id_token['aud'] != client_id + else + jwks = ->(o) { JSON.parse(UrlCache.get(@metadata['jwks_uri'], force_reload: o[:invalidate])) } + id_token, = JWT.decode id_token, nil, true, { algorithms: %w[RS256 RS512 ES256 ES512], jwks: jwks } + end + + halt 400, 'wrong nonce' if id_token['nonce'] != cached[:nonce] + + user = generate_extern_user(@provider, userinfo) + user.auth_time = id_token['auth_time'] || Time.now.to_i + login_finished user, false, remember_me: true +end diff --git a/plugins/federation/federation_siop.haml b/plugins/federation/federation_siop.haml new file mode 100644 index 0000000..51b48f5 --- /dev/null +++ b/plugins/federation/federation_siop.haml @@ -0,0 +1,27 @@ +:javascript + var es = new EventSource('./#{locals[:provider_id]}/stream?state=#{locals[:state]}'); + es.onmessage = function(e) { console.log(e.data + "\n"); window.location='./#{locals[:provider_id]}/callback?'+e.data }; + +%link{:type => "text/css", :rel => "stylesheet", :href => "../css/main.css"} + +%div{ :class => "header"} + %form{:action => "../login", :method => "get"} + %input{:type => "submit", :value => "Go Back...", :class => "button"} + +
+%h2 Please scan the following QR code to authenticate with your phone + +%br + +!= locals[:qr] + +%br + +%a{:href => locals[:cross_device_href]} Debugging Link + +%br +%h2 Or you can click the button below to authenticate using an app on this device + +%br + +%a{:href => locals[:href], :class => "button"} Click here \ No newline at end of file diff --git a/plugins/postgres_backend/postgres_backend.rb b/plugins/postgres_backend/postgres_backend.rb new file mode 100644 index 0000000..0d8f27b --- /dev/null +++ b/plugins/postgres_backend/postgres_backend.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'pg' + +# Load the code for each type of event +require_relative 'storage_config' +require_relative 'storage_keys' +require_relative 'storage_users' +require_relative 'storage_clients' + +# The main class for this plugin +class PostgresBackendPlugin + class << self; attr_accessor :config, :database end + @config = {} # Database configuration + @database = nil # Database connection + + # init reads the static configuration + # and registers the event handlers + def self.init + # Default configuration + @config = { + # Can contain any postgres connection parameters, + # see https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS + 'connection' => {}, + # Include 'keys', 'config', 'user' and/or 'client' to register the event handlers + 'handlers' => %w[ + keys + config + client + user + ] + }.merge(PluginLoader.configuration('postgres_backend') || {}) + + # Connect to the db and optionally create the relevant tables. + # Register any handlers + db = connect_db + PostgresClientDB.init db if @config['handlers'].include? 'client' + PostgresUserDB.init db if @config['handlers'].include? 'user' + PostgresConfigDB.init db if @config['handlers'].include? 'config' + PostgresKeysDB.init db if @config['handlers'].include? 'keys' + end + + # A helper to check if a relation exists + def self.relation_exists(name) + result = false + connect_db.exec_params "SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = $1", [name] do |res| + result = res.any? + end + end + + # Connects to a database + def self.connect_db + PostgresBackendPlugin.database ||= PG.connect @config['connection'] + end +end + +# Start initialization upon startup +PostgresBackendPlugin.init diff --git a/plugins/postgres_backend/storage_attributes.rb b/plugins/postgres_backend/storage_attributes.rb new file mode 100644 index 0000000..ce59f17 --- /dev/null +++ b/plugins/postgres_backend/storage_attributes.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# User Attributes for users and clients +class PostgresAttributeDB + TABLE_ATTRIBUTES = 'attributes' + def self.write_attributes(type, identifier, attributes) + PostgresBackendPlugin.connect_db.transaction do |t| + t.exec_params "DELETE FROM #{TABLE_ATTRIBUTES} WHERE type = $1 AND identifier = $2", [type, identifier] + attributes.each do |a| + t.exec_params "INSERT INTO #{TABLE_ATTRIBUTES}(type,identifier,key,value) VALUES ($1,$2,$3,$4)", + [type, identifier, a['key'], (JSON.generate a['value'])] + end + end + end + + def self.read_attributes(type, identifier) + db = PostgresBackendPlugin.connect_db + db.exec_params "SELECT * FROM #{TABLE_ATTRIBUTES} WHERE type = $1 AND identifier = $2", + [type, identifier] do |result| + retval = [] + result.each do |v| + key, value = v.values_at('key', 'value') + retval << { 'key' => key, 'value' => (JSON.parse value) } + end + return retval + end + [] + end + + def self.init(db) + # Create table + return if PostgresBackendPlugin.relation_exists TABLE_ATTRIBUTES + + db.exec_params "CREATE TABLE #{TABLE_ATTRIBUTES} (type TEXT, identifier TEXT, key TEXT, value TEXT)" + end +end diff --git a/plugins/postgres_backend/storage_clients.rb b/plugins/postgres_backend/storage_clients.rb new file mode 100644 index 0000000..fae2fc9 --- /dev/null +++ b/plugins/postgres_backend/storage_clients.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require_relative 'storage_attributes' + +# The PostgreSQL backend for client data. Attributes are saved separately from Metadata +class PostgresClientDB + TABLE_CLIENT_METADATA = 'clients' + def self.get(bind) + client_id = bind.local_variable_get :client_id + client_hash = {} + PostgresBackendPlugin.connect_db.exec_params "SELECT * FROM #{TABLE_CLIENT_METADATA} WHERE client_id = $1", + [client_id] do |result| + result.each do |v| + key, value = v.values_at('key', 'value') + client_hash[key] = JSON.parse value + end + end + return nil unless client_hash['client_id'] + + client_hash['attributes'] = PostgresAttributeDB.read_attributes 'client', client_id + client_hash['backend'] = 'postgres' + Client.from_h client_hash + end + + def self.get_all(*) + clients_hash = {} + PostgresBackendPlugin.connect_db.exec_params "SELECT * FROM #{TABLE_CLIENT_METADATA}" do |result| + result.each do |v| + client_id, key, value = v.values_at('client_id', 'key', 'value') + clients_hash[client_id] ||= {} + clients_hash[client_id][key] = JSON.parse value + end + end + clients = clients_hash.values + clients.each do |c| + c['attributes'] = PostgresAttributeDB.read_attributes 'client', c['client_id'] + c['backend'] = 'postgres' + end + clients.map { |c| Client.from_h c } + end + + def self.create(bind) + client = bind.local_variable_get :client + metadata = client.metadata + PostgresAttributeDB.write_attributes 'client', client.client_id, client.attributes + PostgresBackendPlugin.connect_db.transaction do |t| + metadata.each do |key, value| + t.exec_params "INSERT INTO #{TABLE_CLIENT_METADATA}(client_id,key,value) VALUES ($1,$2,$3)", + [client.client_id, key, (JSON.generate value)] + end + end + end + + def self.update(bind) + client = bind.local_variable_get :client + client_id = client.client_id + delete(binding) + create(binding) + end + + def self.delete(bind) + client_id = bind.local_variable_get :client_id + db = PostgresBackendPlugin.connect_db + db.exec_params "DELETE FROM #{TABLE_CLIENT_METADATA} WHERE client_id = $1", [client_id] + PostgresAttributeDB.write_attributes 'client', client_id, [] + end + + # Forward to key storage + def self.certificate_get(bind) + DefaultClientDB.certificate_get bind + end + + # Forward to key storage + def self.certificate_update(bind) + DefaultClientDB.certificate_update bind + end + + def self.init(db) + # Create the tables + unless PostgresBackendPlugin.relation_exists TABLE_CLIENT_METADATA + db.exec_params "CREATE TABLE #{TABLE_CLIENT_METADATA} (client_id TEXT, key TEXT, value TEXT)" + end + PostgresAttributeDB.init(db) + + # Register event handlers + PluginLoader.register 'CLIENT_GET', method(:get) + PluginLoader.register 'CLIENT_GET_ALL', method(:get_all) + PluginLoader.register 'CLIENT_CREATE', method(:create) + PluginLoader.register 'CLIENT_UPDATE', method(:update) + PluginLoader.register 'CLIENT_DELETE', method(:delete) + PluginLoader.register 'CLIENT_AUTHENTICATION_CERTIFICATE_GET', method(:certificate_get) + PluginLoader.register 'CLIENT_AUTHENTICATION_CERTIFICATE_UPDATE', method(:certificate_update) + end +end diff --git a/plugins/postgres_backend/storage_config.rb b/plugins/postgres_backend/storage_config.rb new file mode 100644 index 0000000..5455363 --- /dev/null +++ b/plugins/postgres_backend/storage_config.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# The PostgreSQL backend for configuration data uses two schemas. +# Arrays are saved as single JSON-encoded values, while +# Hashes are saved as key-value pairs +class PostgresConfigDB + TABLE_CONFIG_PREFIX = 'configuration_' + + # To write a config section, we overwrite everything + def self.write_config(bind) + section = bind.local_variable_get :section + data = bind.local_variable_get :data + table_name = TABLE_CONFIG_PREFIX + section # FIXME: Possible SQL Injection + PostgresBackendPlugin.connect_db.transaction do |t| + case data.class.name + when 'Array' + t.exec_params "CREATE TABLE #{table_name}(item TEXT)" unless PostgresBackendPlugin.relation_exists table_name + t.exec_params "DELETE FROM #{table_name}" + data.each do |item| + t.exec_params "INSERT INTO #{table_name}(item) VALUES ($1)", [JSON.generate(item)] + end + when 'Hash' + unless PostgresBackendPlugin.relation_exists table_name + t.exec_params "CREATE TABLE #{table_name}(key TEXT PRIMARY KEY, value TEXT)" + end + t.exec_params "DELETE FROM #{table_name}" + data.each do |key, value| + t.exec_params "INSERT INTO #{table_name}(key,value) VALUES ($1,$2)", [key.to_s, JSON.generate(value)] + end + end + end + end + + # To read a config section, we use the fallback to determine the type + def self.read_config(bind) + section = bind.local_variable_get :section + fallback = bind.local_variable_get :fallback + table_name = TABLE_CONFIG_PREFIX + section + retval = nil + PostgresBackendPlugin.connect_db.exec_params "SELECT * FROM #{table_name}" do |result| + case fallback.class.name + when 'Array' + retval = [] + result.each { |v| retval << (JSON.parse v.values_at('item')) } + when 'Hash' + retval = {} + result.each do |v| + key, value = v.values_at('key', 'value') + retval[key] = JSON.parse value + end + else + retval = fallback # Unknown type + end + end + retval + rescue StandardError => e + p e unless e.is_a? PG::UndefinedTable # Config section not yet written to, ignore... + fallback + end + + def self.init(_db) + # Register event handlers + PluginLoader.register 'CONFIGURATION_STORE', method(:write_config) + PluginLoader.register 'CONFIGURATION_LOAD', method(:read_config) + end +end diff --git a/plugins/postgres_backend/storage_keys.rb b/plugins/postgres_backend/storage_keys.rb new file mode 100644 index 0000000..adb6b36 --- /dev/null +++ b/plugins/postgres_backend/storage_keys.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +# The PostgreSQL backend for keys stores values indexed by target_type, target and type +# The cryptographic items themselves are stored as PEM +class PostgresKeysDB + TABLE_KEYS = 'keys' + CERTIFICATE_CHAIN_SEPARATOR = '_' + + def self.store_key(bind) + target_type = bind.local_variable_get :target_type + target = bind.local_variable_get :target + key_material = bind.local_variable_get :key_material + + PostgresBackendPlugin.connect_db.transaction do |t| + t.exec_params "DELETE FROM #{TABLE_KEYS} WHERE target_type = $1 AND target = $2", [target_type, target] + if key_material['sk'] + t.exec_params "INSERT INTO #{TABLE_KEYS}(target_type,target,type,value) VALUES ($1,$2,$3,$4)", + [target_type, target, 'sk', key_material['sk'].to_pem] + end + if key_material['certs'] + t.exec_params "INSERT INTO #{TABLE_KEYS}(target_type,target,type,value) VALUES ($1,$2,$3,$4)", + [target_type, target, 'certs', + key_material['certs'].map(&:to_pem).join(CERTIFICATE_CHAIN_SEPARATOR)] + end + end + end + + def self.load_key(bind) + target_type = bind.local_variable_get :target_type + target = bind.local_variable_get :target + db = PostgresBackendPlugin.connect_db + retval = {} + db.exec_params "SELECT * FROM #{TABLE_KEYS} WHERE target_type = $1 AND target = $2", + [target_type, target] do |result| + result.each do |v| + key, value = v.values_at('type', 'value') + case key + when 'sk' + retval[key] = OpenSSL::PKey::RSA.new value + when 'certs' + retval[key] = value.split(CERTIFICATE_CHAIN_SEPARATOR).map do |c| + OpenSSL::X509::Certificate.new c + end + end + end + end + retval['pk'] = retval.dig('certs', 0)&.public_key || retval['sk']&.public_key + retval + end + + def self.load_all_keys(bind) + target_type = bind.local_variable_get :target_type + db = PostgresBackendPlugin.connect_db + retval = {} + db.exec_params "SELECT * FROM #{TABLE_KEYS} WHERE target_type = $1", [target_type] do |result| + result.each do |v| + target, key, value = v.values_at('target', 'type', 'value') + retval[target] ||= {} + case key + when 'sk' + retval[target][key] = OpenSSL::PKey::RSA.new value + when 'certs' + retval[target][key] = value.split(CERTIFICATE_CHAIN_SEPARATOR).map do |c| + OpenSSL::X509::Certificate.new c + end + end + end + end + retval = retval.values + retval.each { |k| k['pk'] = k.dig('certs', 0)&.public_key || k['sk']&.public_key } + retval + end + + def self.init(db) + # Create the table + unless PostgresBackendPlugin.relation_exists TABLE_KEYS + db.exec_params "CREATE TABLE #{TABLE_KEYS} (target_type TEXT, target TEXT, type TEXT, value TEXT)" + end + + # register handlers + PluginLoader.register 'KEYS_STORE', method(:store_key) + PluginLoader.register 'KEYS_LOAD', method(:load_key) + PluginLoader.register 'KEYS_LOAD_ALL', method(:load_all_keys) + end +end diff --git a/plugins/postgres_backend/storage_users.rb b/plugins/postgres_backend/storage_users.rb new file mode 100644 index 0000000..77ba87a --- /dev/null +++ b/plugins/postgres_backend/storage_users.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require_relative 'storage_attributes' + +# The PostgreSQL backend for user data. Attributes are saved separately from Metadata +class PostgresUserDB + TABLE_USERS = 'users' + def self.get(bind) + username = bind.local_variable_get :username + user_hash = {} + PostgresBackendPlugin.connect_db.exec_params "SELECT * FROM #{TABLE_USERS} WHERE username = $1", + [username] do |result| + result.each do |v| + username, password = v.values_at('username', 'password') + user_hash['username'] = username + user_hash['password'] = password + end + end + return nil unless user_hash['username'] + + user_hash['attributes'] = PostgresAttributeDB.read_attributes 'user', username + user_hash['backend'] = 'postgres' + User.from_h user_hash + end + + def self.get_all(*) + users = [] + PostgresBackendPlugin.connect_db.exec_params "SELECT * FROM #{TABLE_USERS}" do |result| + result.each do |v| + username, password = v.values_at('username', 'password') + users << { + 'username' => username, + 'password' => password + } + end + end + users.each do |c| + c['attributes'] = PostgresAttributeDB.read_attributes 'user', c['username'] + c['backend'] = 'postgres' + end + users.map { |c| User.from_h c } + end + + def self.create(bind) + user = bind.local_variable_get :user + PostgresAttributeDB.write_attributes 'user', user.username, user.attributes + PostgresBackendPlugin.connect_db.transaction do |t| + t.exec_params "INSERT INTO #{TABLE_USERS}(username,password) VALUES ($1,$2)", + [user.username, user.password] + end + end + + def self.update(bind) + user = bind.local_variable_get :user + username = user.username + delete(binding) + create(binding) + end + + def self.delete(bind) + username = bind.local_variable_get :username + db = PostgresBackendPlugin.connect_db + db.exec_params "DELETE FROM #{TABLE_USERS} WHERE username = $1", [username] + PostgresAttributeDB.write_attributes 'user', username, [] + end + + # Forward to key storage + def self.update_password(bind) + user = bind.local_variable_get('user') + password = bind.local_variable_get('password') + db.exec_params "UPDATE #{TABLE_USERS} SET password = $1 WHERE username = $2", + [(User.string_to_pass_hash password), user.username] + end + + # Forward to key storage + def self.verify_password(bind) + user = bind.local_variable_get('user') + password = bind.local_variable_get('password') + return unless user.backend == 'postgres' + + user.password == password + end + + def self.init(db) + # Create the tables + unless PostgresBackendPlugin.relation_exists TABLE_USERS + db.exec_params "CREATE TABLE #{TABLE_USERS} (username TEXT PRIMARY KEY, password TEXT)" + end + PostgresAttributeDB.init(db) + + # Register event handlers + PluginLoader.register 'USER_GET', method(:get) + PluginLoader.register 'USER_GET_ALL', method(:get_all) + PluginLoader.register 'USER_CREATE', method(:create) + PluginLoader.register 'USER_UPDATE', method(:update) + PluginLoader.register 'USER_DELETE', method(:delete) + PluginLoader.register 'USER_AUTHENTICATION_PASSWORD_CHANGE', method(:update_password) + PluginLoader.register 'USER_AUTHENTICATION_PASSWORD_VERIFY', method(:verify_password) + end +end diff --git a/plugins/token_user_attributes/token_user_attributes.rb b/plugins/token_user_attributes/token_user_attributes.rb new file mode 100644 index 0000000..140af99 --- /dev/null +++ b/plugins/token_user_attributes/token_user_attributes.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative '../../lib/oauth_helper' + +# Maps Userinfo Claims to the Access- and ID tokens +# Includes any specifically requested claims as-is +class TokenUserAttributesPlugin + attr_reader :config + + def initialize(config) + @config = config || {} + PluginLoader.register 'TOKEN_CREATED_ACCESS_TOKEN', method(:map_to_access_token) + PluginLoader.register 'TOKEN_CREATED_ID_TOKEN', method(:map_to_id_token) + end + + def map_to_access_token(bind) + map_to_token bind, 'access_token' unless @config['skip_access_token'] + end + + def map_to_id_token(bind) + map_to_token bind, 'id_token' unless @config['skip_id_token'] + end + + def map_to_token(bind, sink) + token = bind.local_variable_get 'token' + client = bind.local_variable_get 'client' + user = bind.local_variable_get 'user' + scopes = bind.local_variable_get 'scopes' + claims = bind.local_variable_get 'claims' + token.merge!(OAuthHelper.map_claims_to_userinfo((user || client).attributes, claims[sink], client, scopes)) + end +end + +TokenUserAttributesPlugin.new Config.base_config.dig('plugins', 'token_user_attributes') diff --git a/plugins/user_db/ldap.rb b/plugins/user_backend_ldap/user_backend_ldap.rb similarity index 85% rename from plugins/user_db/ldap.rb rename to plugins/user_backend_ldap/user_backend_ldap.rb index fb6576b..f5dad59 100644 --- a/plugins/user_db/ldap.rb +++ b/plugins/user_backend_ldap/user_backend_ldap.rb @@ -3,10 +3,9 @@ require 'socket' require 'net/ldap' require 'base64' -require_relative './_abstract' # LDAP User DB backend -class LdapUserDb < UserDb +class LdapUserDb attr_reader :config @dn_cache = {} @@ -19,6 +18,10 @@ def initialize(config) 'base_dn' => '', 'uid_key' => 'dn' }.merge(config || {}) + + PluginLoader.register 'USER_GET', method(:find_by_id) + PluginLoader.register 'USER_GET_ALL', method(:all_users) + PluginLoader.register 'USER_AUTHENTICATION_PASSWORD_VERIFY', method(:verify_password) end def decode_value(value, encoding) @@ -60,7 +63,7 @@ def ldap_entry_to_user(entry) end end user['attributes'].compact! - User.from_dict user + User.from_h user end def all_users @@ -83,7 +86,11 @@ def lookup_user(username) nil end - def verify_password(user, password) + def verify_password(bind) + user = bind.local_variable_get('user') + password = bind.local_variable_get('password') + return unless user.backend == 'ldap' + user_dn = lookup_user(user.username) if user_dn.nil? return false if user_dn.nil? @@ -119,7 +126,8 @@ def connect_directory(bind_dn = nil, bind_pass = nil) dir end - def find_by_id(username) + def find_by_id(bind) + username = bind.local_variable_get 'username' ldap = connect_directory uid_key = @config['uidKey'] filter = Net::LDAP::Filter.eq(uid_key, username) @@ -132,9 +140,4 @@ def find_by_id(username) end end -# Monkey patch the loader -class PluginLoader - def self.load_user_db_ldap - LdapUserDb.new - end -end +YamlUserDb.new Config.base_config.dig('plugins', 'user_backend_ldap') diff --git a/plugins/user_db/_abstract.rb b/plugins/user_db/_abstract.rb deleted file mode 100644 index 7f7a541..0000000 --- a/plugins/user_db/_abstract.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -# Abstract UserDb interface -class UserDb - def create_user(user) - raise NotImplementedError - end - - def delete_user(username) - raise NotImplementedError - end - - def update_user(user) - raise NotImplementedError - end - - def all_users - raise NotImplementedError - end - - def find_by_id(user) - raise NotImplementedError - end - - def update_password(user, new_password) - raise NotImplementedError - end - - def verify_password(user, password) - raise NotImplementedError - end -end diff --git a/plugins/user_db/sqlite.rb b/plugins/user_db/sqlite.rb deleted file mode 100644 index 766d239..0000000 --- a/plugins/user_db/sqlite.rb +++ /dev/null @@ -1,105 +0,0 @@ -# frozen_string_literal: true - -require 'sqlite3' -require_relative './_abstract' - -# The SQlite DB plugin for users -class SqliteUserDb < UserDb - attr_reader :config - - def initialize(config) - super() - @config = { - 'location' => 'config/users.db' - }.merge(config || {}) - return if File.exist? @config['location'] - - db = connect_db - db.execute 'CREATE TABLE password(username TEXT PRIMARY KEY, password TEXT)' - db.execute 'CREATE TABLE attributes(username TEXT, key TEXT, value TEXT, PRIMARY KEY (username, key))' - db.close - end - - def create_user(user) - db = connect_db - db.execute 'INSERT INTO password(username, password) VALUES(?, ?)', user.username, user.password - user.attributes.each do |attribute| - db.execute 'INSERT INTO attributes (username, key, value) VALUES (?, ?, ?)', user.username, attribute['key'], - attribute['value'] - end - db.close - end - - def delete_user(username) - db = connect_db - return false unless user_in_db(username, db) - - db.execute 'DELETE FROM password WHERE username=?', username - db.execute 'DELETE FROM attributes WHERE username=?', username - true - end - - def update_user(user) - db = connect_db - return false unless user_in_db(user.username, db) - - db.execute 'DELETE FROM attributes WHERE username=?', user.username - user.attributes.each do |attribute| - db.execute 'INSERT OR REPLACE INTO attributes (username, key, value) VALUES (?, ?, ?)', user.username, - attribute['key'], attribute['value'] - end - db.close - true - end - - def verify_password(user, password) - user.password == password - end - - def all_users - db = connect_db - users = db.execute 'SELECT * FROM password' - users.each do |user| - user['backend'] = 'sqlite' - user['attributes'] = - db.execute 'SELECT key, value FROM attributes WHERE attributes.username = ?', user['username'] - end - db.close - users.map { |user| User.from_dict user } - end - - def update_password(user, password) - db = connect_db - return false unless user_in_db(user.username, db) - - db.execute 'UPDATE password SET password=? WHERE username=?', password, user.username - end - - def find_by_id(username) - db = connect_db - user = db.execute 'SELECT * FROM password WHERE username=?', username - return nil if user.empty? - - user = user[0] - user['attributes'] = db.execute 'SELECT key, value FROM attributes WHERE attributes.username = ?', username - user['backend'] = 'sqlite' - User.from_dict user - end - - def connect_db - db = SQLite3::Database.open @config['location'] - db.results_as_hash = true - db - end - - def user_in_db(_username, db) - (db.execute 'SELECT EXISTS(SELECT 1 FROM password WHERE username=?)', user.username).dig(0, 0) == 1 - end -end - -# Monkey patch the loader -class PluginLoader - def self.load_user_db_sqlite(config) - SqliteUserDb.new config - end -end diff --git a/plugins/user_db/yaml.rb b/plugins/user_db/yaml.rb deleted file mode 100644 index a1ec8dd..0000000 --- a/plugins/user_db/yaml.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -require_relative './_abstract' - -# The DB backend for yaml files -class YamlUserDb < UserDb - attr_reader :config - - def initialize(config) - super() - @config = { - 'location' => 'config/users.yml' - }.merge(config || {}) - Config.write_config(db_file, [].to_yaml) unless File.exist? @config['location'] - end - - def create_user(user) - users = all_users - users << user - write_user_db users - end - - def delete_user(username) - user = find_by_id username - return false unless user - - users = all_users - users.delete(user) - write_user_db users - true - end - - def update_user(user) - users = all_users - idx = users.index user - return false unless idx - - users[idx] = user - write_user_db users - true - end - - def all_users - ((YAML.safe_load File.read db_file) || []).map do |user| - user['backend'] = 'yaml' - User.from_dict user - end - end - - def update_password(user, password) - user.password = password - update_user(user) - end - - def verify_password(user, password) - user.password == password - end - - def find_by_id(username) - ((YAML.safe_load File.read db_file) || []).each do |user| - next unless user['username'] == username - - user['backend'] = 'yaml' - return User.from_dict user - end - nil - end - - private - - def db_file - @config['location'] - end - - def write_user_db(users) - Config.write_config(db_file, users.map(&:to_dict).map do |u| - u.delete('backend') - u - end.to_yaml) - end -end - -# Monkey patch the loader -class PluginLoader - def self.load_user_db_yaml(config) - YamlUserDb.new config - end -end diff --git a/plugins/api/user_selfservice_v1.rb b/plugins/user_selfservice/user_selfservice.rb similarity index 94% rename from plugins/api/user_selfservice_v1.rb rename to plugins/user_selfservice/user_selfservice.rb index 12646a3..96017be 100644 --- a/plugins/api/user_selfservice_v1.rb +++ b/plugins/user_selfservice/user_selfservice.rb @@ -18,7 +18,7 @@ user_may_read = !(scopes & ['omejdn:admin', 'omejdn:write', 'omejdn:read']).empty? halt 403 unless request.env['REQUEST_METHOD'] == 'GET' ? user_may_read : user_may_write @user = User.find_by_id token['sub'] - @selfservice_config = Config.base_config.dig('plugins', 'api', 'user_selfservice_v1') || { + @selfservice_config = PluginLoader.configuration('user_selfservice') || { 'editable_attributes' => [], 'allow_deletion' => false, 'allow_password_change' => false @@ -38,6 +38,8 @@ updated_user = User.new updated_user.username = @user.username updated_user.attributes = [] + updated_user.backend = @user.backend + updated_user.extern = @user.extern JSON.parse(request.body.read)['attributes'].each do |e| updated_user.attributes << e if editable.include? e['key'] end diff --git a/public/css/main.css b/public/css/main.css index 5da6421..8857135 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -22,7 +22,14 @@ h1,h2,h3,h4,h5,h6 { margin-left: 0; margin-right: 0; background: #6FA8DC; - padding: 20px; + padding-top: 2.5px; + padding-bottom: 2.5px; + padding-left: 20px; +} + +.header form { + margin: 0; + display: inline } form input { diff --git a/scripts/test_all.sh b/scripts/test_all.sh index 63b4175..324c343 100755 --- a/scripts/test_all.sh +++ b/scripts/test_all.sh @@ -1,9 +1,10 @@ #!/bin/sh -testfiles="$(ls -1 tests/test_*.rb)" +testfiles="$(ls -1 tests/test_*.rb; ls -1 tests/plugins/test*.rb)" for file in $testfiles; do - bundle exec ruby "$file" + echo "Testing $file" + bundle exec ruby "$file" --use-color if [ "$?" -ne "0" ]; then echo "Error: Test $file unsucessful" exit 1 diff --git a/tests/config_testsetup.rb b/tests/config_testsetup.rb index 784717e..a1328f4 100644 --- a/tests/config_testsetup.rb +++ b/tests/config_testsetup.rb @@ -1,33 +1,78 @@ # frozen_string_literal: true -# Always load this BEFORE omejdn.rb +# We want to intercept any storage request Omejdn makes, +# so we do not actually overwrite any data. +# To do this, we first edit ENV, +# so Omejdn loads our custom plugins file. +# Tests may add additional plugins +plugin_files = ENV['OMEJDN_PLUGINS']&.split(':') || [] +plugin_files << 'tests/test_resources/setup.yml' +ENV.clear # Fresh ENV +ENV['OMEJDN_PLUGINS'] = plugin_files.join(':') -require 'yaml' +# Next we write our own handler for storage requests, +# which simply saves any data in this class +require_relative '../lib/plugins' +class TestDB + class << self; attr_accessor :config, :keys end + @config = {} # The Config DB + @keys = {} # The Keys DB -class TestSetup + def self.write_config(bind) + section = bind.local_variable_get :section + data = bind.local_variable_get :data + @config[section] = data + end + + def self.read_config(bind) + section = bind.local_variable_get :section + fallback = bind.local_variable_get :fallback + JSON.parse((@config[section] || fallback).to_json) # Simple deep copy + end - def self.backup - @backup_clients = File.read './config/clients.yml' rescue nil - @backup_omejdn = File.read './config/omejdn.yml' rescue nil - File.open('./config/users_test.yml', 'w') { |file| file.write(users.to_yaml) } - File.open('./config/clients.yml', 'w') { |file| file.write(clients.to_yaml) } - File.open('./config/omejdn.yml', 'w') { |file| file.write(config.to_yaml) } + def self.store_key(bind) + target_type = bind.local_variable_get :target_type + target = bind.local_variable_get :target + key_material = bind.local_variable_get :key_material + raise 'ERROR' if key_material['pk'].nil? && key_material.keys.length.positive? + (@keys[target_type] ||= {})[target] = key_material end - def self.setup - File.open('./keys/omejdn/omejdn_test.cert', 'w') do |file| - file.write (File.read './tests/test_resources/omejdn_test.cert') - end - File.open('./config/users_test.yml', 'w') { |file| file.write(users.to_yaml) } - File.open('./config/clients.yml', 'w') { |file| file.write(clients.to_yaml) } - File.open('./config/omejdn.yml', 'w') { |file| file.write(config.to_yaml) } + def self.load_key(bind) + target_type = bind.local_variable_get :target_type + target = bind.local_variable_get :target + create_key = bind.local_variable_get :create_key + @keys.dig(target_type, target) || {} end - def self.teardown - File.delete './config/users_test.yml' - File.delete './keys/omejdn/omejdn_test.cert' - File.open('./config/clients.yml', 'w') { |file| file.write(@backup_clients) } - File.open('./config/omejdn.yml', 'w') { |file| file.write(@backup_omejdn) } + def self.load_all_keys(bind) + target_type = bind.local_variable_get :target_type + (@keys[target_type] || {}).values + end + + PluginLoader.register 'CONFIGURATION_STORE', method(:write_config) + PluginLoader.register 'CONFIGURATION_LOAD', method(:read_config) + PluginLoader.register 'KEYS_STORE', method(:store_key) + PluginLoader.register 'KEYS_LOAD', method(:load_key) + PluginLoader.register 'KEYS_LOAD_ALL', method(:load_all_keys) +end + +# Finally we load Omejdn +require_relative '../omejdn' + +# Omejdn should be initialized with its default configuration now, +# so you can use the core functionality to store data. +# Alternatively, e.g. if that functionality is what you are testing, +# you can also just access TestDB.config and TestDB.keys directly. + +# For convenience, a few functions to help with setting up the configuration +# These do not depend on Omejdn and edit the TestDB directly +# Call them inside setup and teardown +class TestSetup + def self.setup(config: {}, clients: [], users: []) + TestDB.config['omejdn'].merge!(config) + TestDB.config['clients'] = TestSetup.clients + TestDB.config['users'] = TestSetup.users end def self.users @@ -60,7 +105,7 @@ def self.users ], 'password' => '$2a$12$s1UhO7bRO9b5fTTiRE4KxOR88vz3462Bxn8DGh/iDX26Neh95AHrC', 'backend' => 'yaml' - }] + }]#.map { |u| User.from_h(u) } end def self.clients @@ -113,7 +158,7 @@ def self.clients { 'key' => 'dynattribute', 'dynamic' => true }, {'key'=> 'omejdn', 'value'=> 'write'} ] - }] + }]#.map { |c| Client.from_h(c) } end def self.config @@ -122,8 +167,8 @@ def self.config 'front_url' => 'http://localhost:4567', 'bind_to' => '0.0.0.0:4567', 'environment' => 'test', - 'openid' => true, - 'default_audience' => 'TestServer', + 'openid' => false, + 'default_audience' => [], 'accept_audience' => 'http://localhost:4567', 'user_backend_default' => 'yaml', 'access_token' => { @@ -133,28 +178,8 @@ def self.config 'id_token' => { 'expiration' => 3600, 'algorithm' => 'RS256', - }, - 'plugins' => { - 'user_db' => { - 'yaml' => { - 'location' => 'config/users_test.yml' - } - }, - 'api' => { - 'admin_v1' => nil, - 'user_selfservice_v1' => { - 'allow_deletion' => true, - 'allow_password_change' => true, - 'editable_attributes' => ['name'] - } - }, - 'claim_mapper' => { - 'attribute' => nil - } } } + TestDB.config['omejdn'] end end - -# Backup all Config Files -TestSetup.backup \ No newline at end of file diff --git a/tests/test_admin_api.rb b/tests/plugins/test_admin_api.rb similarity index 94% rename from tests/test_admin_api.rb rename to tests/plugins/test_admin_api.rb index a059856..29e3574 100644 --- a/tests/test_admin_api.rb +++ b/tests/plugins/test_admin_api.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true require 'test/unit' require 'rack/test' -require_relative 'config_testsetup' -require_relative '../omejdn' -require_relative '../lib/token' + +ENV['OMEJDN_PLUGINS'] = 'tests/test_resources/plugins_test_admin_api.yml' +require_relative '../config_testsetup' +require_relative '../../lib/token' class AdminApiTest < Test::Unit::TestCase include Rack::Test::Methods @@ -19,11 +20,7 @@ def setup @client2 = Client.find_by_id 'publicClient' @token = Token.access_token @client, nil, ['omejdn:admin'], {}, TestSetup.config['front_url']+"/api" @insufficient_token = Token.access_token @client, nil, ['omejdn:write'], {}, "test" - @testCertificate = File.read './tests/test_resources/testClient.pem' - end - - def teardown - TestSetup.teardown + @testCertificate = OpenSSL::X509::Certificate.new File.read('./tests/test_resources/testClient.pem') end def test_require_admin_scope @@ -128,11 +125,11 @@ def test_put_clients def test_get_client get "/api/v1/config/clients/#{@client.client_id}", {}, { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" } assert last_response.ok? - assert_equal @client.to_dict, JSON.parse(last_response.body) + assert_equal @client.to_h, JSON.parse(last_response.body) end def test_put_client - client_desc = @client.to_dict + client_desc = @client.to_h client_desc.delete("client_id") client_desc['name'] = "Alternative Name" put "/api/v1/config/clients/#{@client.client_id}", client_desc.to_json, { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" } @@ -182,7 +179,7 @@ def test_put_config def test_post_put_delete_certificate cert = { - 'certificate' => @testCertificate + 'certificate' => @testCertificate.to_pem } post "/api/v1/config/clients/#{@client.client_id}/keys", cert.to_json, { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" } diff --git a/tests/test_selfservice_api.rb b/tests/plugins/test_selfservice_api.rb similarity index 94% rename from tests/test_selfservice_api.rb rename to tests/plugins/test_selfservice_api.rb index dfe1dee..700d859 100644 --- a/tests/test_selfservice_api.rb +++ b/tests/plugins/test_selfservice_api.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true require 'test/unit' require 'rack/test' -require_relative 'config_testsetup' -require_relative '../omejdn' -require_relative '../lib/token' + +ENV['OMEJDN_PLUGINS'] = 'tests/test_resources/plugins_test_selfservice_api.yml' +require_relative '../config_testsetup' +require_relative '../../lib/token' class SelfServiceApiTest < Test::Unit::TestCase include Rack::Test::Methods @@ -21,10 +22,6 @@ def setup @useless_token = Token.access_token client, user, [], {}, TestSetup.config['front_url']+"/api" end - def teardown - TestSetup.teardown - end - def test_require_read_scope get '/api/v1/user', {}, { 'HTTP_AUTHORIZATION' => "Bearer #{@useless_token}" } assert last_response.forbidden? diff --git a/tests/test_jwks.rb b/tests/test_jwks.rb deleted file mode 100644 index 8f66973..0000000 --- a/tests/test_jwks.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true -require 'test/unit' -require 'rack/test' -require_relative 'config_testsetup' -require_relative '../omejdn' - -class JWKSTest < Test::Unit::TestCase - include Rack::Test::Methods - - def app - Sinatra::Application - end - - def setup - TestSetup.setup - end - - def teardown - TestSetup.teardown - end - - def test_jwks - get '/jwks.json' - assert last_response.ok? - jwks = JSON.parse last_response.body - assert_equal 1, jwks.length - assert jwk = jwks['keys'].select{|k| k[:kid] = 'jexs4cfi5p3NUziLELGwTV7r9gZsLcTBnFp-m4vu0aw'}.first - assert_equal "RSA", jwk['kty'] - assert_equal "sig", jwk['use'] - # This check is currently not reliable. - # TODO: Better test setup to copy all keys and certs to the correct positions - # assert_equal 2, jwk['x5c'].length - # assert jwk['x5t'] - end -end diff --git a/tests/test_oauth_2_0.rb b/tests/test_oauth_2_0.rb index d021ae7..007581e 100644 --- a/tests/test_oauth_2_0.rb +++ b/tests/test_oauth_2_0.rb @@ -17,9 +17,9 @@ def setup @priv_key_ec256 = OpenSSL::PKey::EC.new File.read './tests/test_resources/ec256.pem' @priv_key_ec512 = OpenSSL::PKey::EC.new File.read './tests/test_resources/ec512.pem' @priv_key_rsa = OpenSSL::PKey::RSA.new File.read './tests/test_resources/rsa.pem' - @certificate_ec256 = File.read './tests/test_resources/ec256.cert' - @certificate_ec512 = File.read './tests/test_resources/ec512.cert' - @certificate_rsa = File.read './tests/test_resources/rsa.cert' + @certificate_ec256 = OpenSSL::X509::Certificate.new File.read('./tests/test_resources/ec256.cert') + @certificate_ec512 = OpenSSL::X509::Certificate.new File.read('./tests/test_resources/ec512.cert') + @certificate_rsa = OpenSSL::X509::Certificate.new File.read('./tests/test_resources/rsa.cert') TestSetup.setup @@ -33,7 +33,6 @@ def setup end def teardown - TestSetup.teardown @client_private_key_jwt.certificate = nil end @@ -55,7 +54,7 @@ def request_token(grant_type, client, scope, code: nil, pkce: nil, cert: nil, ke }) when 'client_secret_basic' headers.merge! ({ - 'HTTP_AUTHORIZATION' => "Basic #{Base64.encode64("#{client.client_id}:#{client.metadata['client_secret']}")}" + 'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64("#{client.client_id}:#{client.metadata['client_secret']}")}" }) when 'client_secret_post' params.merge! ({ :client_secret => client.metadata['client_secret'] }) @@ -101,9 +100,9 @@ def test_client_credentials_grant assert response at = extract_access_token response - check_keys ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub','omejdn'], at + check_keys ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub'], at assert_equal 'omejdn:write', at['scope'] - assert_equal [TestSetup.config.dig('default_audience'), TestSetup.config['front_url']+'/api'], at['aud'] + assert_equal [TestSetup.config.dig('default_audience'), TestSetup.config['front_url']+'/api'].flatten, at['aud'] assert_equal TestSetup.config.dig('issuer'), at['iss'] assert at['nbf'] <= Time.new.to_i assert_equal at['nbf'], at['iat'] @@ -159,24 +158,24 @@ def test_client_credentials_scope_rejection assert_equal 'omejdn:write', at['scope'] end - def test_client_credentials_dynamic_claims - claims = { - '*' => { - 'dynattribute'=> { # should be included - 'value' => 'myvalue' - }, - 'nondynattribute'=> { # should get rejected - 'value' => 'myvalue' - } - } - } - query_additions = { 'claims' => claims.to_json } - response = request_token 'client_credentials', @client_dyn_claims, 'omejdn:write', query_additions: query_additions - assert response - at = extract_access_token response - assert_equal claims.dig('*','dynattribute','value'), at['dynattribute'] - assert_equal nil, at['nondynattribute'] - end +# def test_client_credentials_dynamic_claims +# claims = { +# '*' => { +# 'dynattribute'=> { # should be included +# 'value' => 'myvalue' +# }, +# 'nondynattribute'=> { # should get rejected +# 'value' => 'myvalue' +# } +# } +# } +# query_additions = { 'claims' => claims.to_json } +# response = request_token 'client_credentials', @client_dyn_claims, 'omejdn:write', query_additions: query_additions +# assert response +# at = extract_access_token response +# assert_equal claims.dig('*','dynattribute','value'), at['dynattribute'] +# assert_equal nil, at['nondynattribute'] +# end # Returns an authorization code or nil def request_authorize(user, client, scope, state: 'teststate', query_additions: nil, pkce_challenge: nil) @@ -229,9 +228,9 @@ def test_authorization_code_grant assert response at = extract_access_token response - check_keys ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub', 'omejdn'], at + check_keys ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub'], at assert_equal 'omejdn:write', at['scope'] - assert_equal [TestSetup.config.dig('default_audience'), TestSetup.config['front_url']+'/api'], at['aud'] + assert_equal [TestSetup.config.dig('default_audience'), TestSetup.config['front_url']+'/api'].flatten, at['aud'] assert_equal TestSetup.config.dig('issuer'), at['iss'] assert at['nbf'] <= Time.new.to_i assert_equal at['nbf'], at['iat'] @@ -239,7 +238,6 @@ def test_authorization_code_grant assert at['jti'] assert_equal @public_client.client_id, at['client_id'] assert_equal TestSetup.users.dig(0,'username'), at['sub'] - assert_equal 'write', at['omejdn'] end def test_authorization_flow_with_bad_resources @@ -258,7 +256,7 @@ def test_authorization_flow_with_resources assert response at = extract_access_token response - check_keys ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub', 'omejdn'], at + check_keys ['scope','aud','iss','nbf','iat','jti','exp','client_id','sub'], at assert_equal 'omejdn:write', at['scope'] assert_equal ['http://example.org', TestSetup.config['front_url']+'/api'], at['aud'] assert_equal TestSetup.config.dig('issuer'), at['iss'] @@ -268,43 +266,42 @@ def test_authorization_flow_with_resources assert at['jti'] assert_equal @resource_client.client_id, at['client_id'] assert_equal TestSetup.users.dig(0,'username'), at['sub'] - assert_equal 'write', at['omejdn'] end - def test_authorization_flow_with_claims - claims = { - '*' => { - 'dynattribute'=> { # should be included - 'value' => 'myvalue' - }, - 'nondynattribute'=> { # should get rejected - 'value' => 'myvalue' - } - } - } - query_additions = { 'claims' => claims.to_json } - code = request_authorize TestSetup.users[2], @client_dyn_claims, 'omejdn:write', query_additions: query_additions - assert code - query_additions.merge!({ - 'redirect_uri' => [*@client_dyn_claims.metadata['redirect_uris']].first - }) - response = request_token 'authorization_code', @client_dyn_claims, 'omejdn:write', code: code, query_additions: query_additions - assert response - at = extract_access_token response - - 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('default_audience'), TestSetup.config['front_url']+'/api'], at['aud'] - assert_equal TestSetup.config.dig('issuer'), at['iss'] - assert at['nbf'] <= Time.new.to_i - assert_equal at['nbf'], at['iat'] - assert_equal at['nbf']+response["expires_in"], at['exp'] - assert at['jti'] - assert_equal @client_dyn_claims.client_id, at['client_id'] - assert_equal TestSetup.users.dig(2,'username'), at['sub'] - assert_equal 'write', at['omejdn'] - assert_equal claims.dig('*','dynattribute','value'), at['dynattribute'] - end +# def test_authorization_flow_with_claims +# claims = { +# '*' => { +# 'dynattribute'=> { # should be included +# 'value' => 'myvalue' +# }, +# 'nondynattribute'=> { # should get rejected +# 'value' => 'myvalue' +# } +# } +# } +# query_additions = { 'claims' => claims.to_json } +# code = request_authorize TestSetup.users[2], @client_dyn_claims, 'omejdn:write', query_additions: query_additions +# assert code +# query_additions.merge!({ +# 'redirect_uri' => [*@client_dyn_claims.metadata['redirect_uris']].first +# }) +# response = request_token 'authorization_code', @client_dyn_claims, 'omejdn:write', code: code, query_additions: query_additions +# assert response +# at = extract_access_token response +# +# 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('default_audience'), TestSetup.config['front_url']+'/api'], at['aud'] +# assert_equal TestSetup.config.dig('issuer'), at['iss'] +# assert at['nbf'] <= Time.new.to_i +# assert_equal at['nbf'], at['iat'] +# assert_equal at['nbf']+response["expires_in"], at['exp'] +# assert at['jti'] +# assert_equal @client_dyn_claims.client_id, at['client_id'] +# assert_equal TestSetup.users.dig(2,'username'), at['sub'] +# assert_equal 'write', at['omejdn'] +# assert_equal claims.dig('*','dynattribute','value'), at['dynattribute'] +# end def test_authorization_flow_with_request_object payload = { diff --git a/tests/test_oauth_helper.rb b/tests/test_oauth_helper.rb deleted file mode 100644 index 4fa03dd..0000000 --- a/tests/test_oauth_helper.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -require_relative '../lib/oauth_helper' -require 'test/unit' - -# Basic OAuth Tester -class TestOAuthHelper < Test::Unit::TestCase - def test_pkce - OAuthHelper.validate_pkce('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', - 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', 'S256') - end -end diff --git a/tests/test_openid.rb b/tests/test_openid.rb index e69de29..1645ce9 100644 --- a/tests/test_openid.rb +++ b/tests/test_openid.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true +require 'test/unit' +require 'rack/test' +require_relative 'config_testsetup' +require_relative '../omejdn' +require_relative '../lib/token' + +class OpenIDTest < Test::Unit::TestCase + include Rack::Test::Methods + + def app + Sinatra::Application + end + + def setup + TestSetup.setup(config: { 'openid' => true } ) + + @client = Client.find_by_id 'publicClient' + @user = User.find_by_id 'testUser' + @token = Token.access_token @client, nil, ['openid'], {}, TestSetup.config['front_url']+"/api" + end + + def test_pkce + OAuthHelper.validate_pkce('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', + 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', 'S256') + end + + def test_jwks + get '/jwks.json' + assert last_response.ok? + jwks = JSON.parse last_response.body + assert_equal 1, jwks.length + assert jwk = jwks['keys'].select{|k| k[:kid] = 'jexs4cfi5p3NUziLELGwTV7r9gZsLcTBnFp-m4vu0aw'}.first + assert_equal "RSA", jwk['kty'] + assert_equal "sig", jwk['use'] + # This check is currently not reliable. + # TODO: Better test setup to copy all keys and certs to the correct positions + # assert_equal 2, jwk['x5c'].length + # assert jwk['x5t'] + end + +end \ No newline at end of file diff --git a/tests/test_resources/plugins_test_admin_api.yml b/tests/test_resources/plugins_test_admin_api.yml new file mode 100644 index 0000000..2cfa6e1 --- /dev/null +++ b/tests/test_resources/plugins_test_admin_api.yml @@ -0,0 +1,2 @@ +plugins: + admin_api: diff --git a/tests/test_resources/plugins_test_selfservice_api.yml b/tests/test_resources/plugins_test_selfservice_api.yml new file mode 100644 index 0000000..8acea66 --- /dev/null +++ b/tests/test_resources/plugins_test_selfservice_api.yml @@ -0,0 +1,6 @@ +plugins: + user_selfservice: + editable_attributes: + - name + allow_deletion: true + allow_password_change: true diff --git a/tests/test_resources/setup.yml b/tests/test_resources/setup.yml new file mode 100644 index 0000000..c905c11 --- /dev/null +++ b/tests/test_resources/setup.yml @@ -0,0 +1,5 @@ +# This file serves to act as a dummy plugin file for Omejdn tests +# It simply deactivates some default plugins, so that the tests do not overwrite anything +deactivate_defaults: +- config +- keys diff --git a/views/authorization_failed.haml b/views/authorization_failed.haml deleted file mode 100644 index 57ba0f7..0000000 --- a/views/authorization_failed.haml +++ /dev/null @@ -1,13 +0,0 @@ -%link{:type => "text/css", :rel => "stylesheet", :href => "css/main.css"} -
-%div - %img{:src => "img/logo.jpg", :class => "logo"} -%form{:action => "#{locals[:host]}/login", :method => "get"} - %fieldset - %h2 Authentication failed - %br - %h3 Error: #{locals[:error]} - %h3 Description: #{locals[:error_description]} - %br - %input{:type => "submit", :value => "Back to login", :class => "button"} -
diff --git a/views/authorization_page.haml b/views/consent.haml similarity index 92% rename from views/authorization_page.haml rename to views/consent.haml index 74db864..bd8efc3 100644 --- a/views/authorization_page.haml +++ b/views/consent.haml @@ -3,7 +3,8 @@ %div{ :class => "header"} %span Currently logged in as %b #{locals[:user].username} - %a{:href => "logout", :class => "button"} Change... + %form{:action => "./logout/exec", :method => "post"} + %input{:type => "submit", :value => "Change...", :class => "button"}
%h1 Authorization Request diff --git a/views/error.haml b/views/error.haml new file mode 100644 index 0000000..a15dc79 --- /dev/null +++ b/views/error.haml @@ -0,0 +1,23 @@ +%link{:type => "text/css", :rel => "stylesheet", :href => "css/main.css"} + +%div{ :class => "header"} + %span This is the Authorization Server at #{error[:iss]} + +
+ +%h2 An Error occured + +%p Whoever sent you here must have misconfigured their service. + +%p If that was you, the following error might help you fix it: + +%table + %tr + %td + %b OAuth Error Code: + %td #{error['error']} + - if error['error_description'] && "" != error['error_description'] + %tr + %td + %b Description: + %td #{error['error_description']} diff --git a/views/login.haml b/views/login.haml index 115b9c8..390636a 100644 --- a/views/login.haml +++ b/views/login.haml @@ -13,14 +13,17 @@ %br %br %input{:type => "submit", :value => "Login", :class => "button"} -%div{:hidden => locals[:providers].empty?} +%div{:hidden => locals[:login_options].empty?} %hr - %h2 Login with external provider: -%div{:class => "idplist", :hidden => locals[:providers].empty?} + - if locals[:no_password_login] + %h2 Please choose your preferred login option: + - else + %h2 Other login options: +%div{:class => "idplist", :hidden => locals[:login_options].empty?} %br - - (locals[:providers]).each do |provider| - %a{:href => provider[:url], :class => "button"} - %span{:hidden => !provider[:logo].nil? } #{provider[:name]} - %img{:src => "#{provider[:logo]}", :hidden => provider[:logo].nil?} + - (locals[:login_options]).each do |opt| + %a{:href => opt[:url], :class => "button"} + %img{:src => "#{opt[:logo]}", :hidden => opt[:logo].nil?} + %span{:hidden => !opt[:logo].nil? } #{opt[:desc]} %br