This module provides an OpenID connect interface for authenticating via
a backend JazzHands database. It currently handles issuing JWT Bearer tokens
for applications for use with applications that use them. It currently only
supports the client_credentials
or password
grant type. Hopefully,
it will eventually support tokens.
This requires a JazzHands database to be setup and is installed in a schema collocated with that.
There are two components -- one is the database backend changes, which is made up of a series of views on top of the JazzHands schema that are placed in a dedicated schema, named and owned by JazzHands_openid .
The second part is a cgi-script that interacts with that schema to issue JWTs. This is provided as a docker container, but it would also be possible to do this outside of docker based on the container build directions. For now, it is written in perl although a drop in rewrite in a different language is expected.
Each service that wants JWTs issued for it needs to be configured and it typically would be a scope difference. It is possible for services to share the same JWT, using the same issuer.
The software requires the database be configured.
- Assuming an existing JazzHands install. There are some assumptions of that install baked into here that are not well documented (such as pgcrypto).
- Run through all the steps inside
all.sql
.- This can be run as SUPERUSER, it assumes roles to minimize risks.
- It will create the
jazzhands_openid
user and schema (if they do not exist). This will be the owner of all the views and whatnot created in the schema, and except for schema changes, never logs in. - It will make appropriate grants so that the schema work.
- It will create a user
app_jazzhands_openid_jwt_minter
and give it grants such that it can interact with views as it needs to.
- Configure service specific endpoints
- insert
service
,service_version
,service_endpoint
- insert to
service_relationship
if this is hosted on another service - use
signkeymgr.pl
to insert a private key in the database and vault or manually insert intoprivate_key1 and
property`. - create account collections and properties for how to define how users work
- insert
[cmdbhost#5 ~/jazzhands-openid] psql -U postgres -d cmdb
psql (12.9 (Ubuntu 12.9-0ubuntu0.20.04.1))
Type "help" for help.
postgres@[local]:jazzhands# begin;
BEGIN
postgres@[local]:jazzhands*# \i all.sql
begin_maintenance
-------------------
t
(1 row)
SET
Pager usage is off.
psql:database/create_scaffolding.sql:32: NOTICE: created jazzhands_openid
DO
ALTER ROLE
GRANT
psql:database/create_scaffolding.sql:53: NOTICE: schema "jazzhands_openid" does not exist, skipping
DO
...
GRANT
postgres@[local]:jazzhands*# alter user app_jazzhands_openid_jwt_minter password 'Vv';
postgres@[local]:jazzhands*# commit;
COMMIT;
This involves some direct database manipulation to setup a key pair for
signing and validating JWTs. The private key is stored in the database (or
better, Hashicorp Vault), and minter
uses it to issue JWTs. It is possible
for multiple services to share the same key pair, or for each service to have
its own. If one claim will have multiple scopes, they will need to share the
same keypair. (Minter does not issue things like this yet, but it could in
the future).
- A service needs to exist in the database first:
user@cmdbhost:cmdb> insert into service (service_name, service_type) values ('testinator-api', 'network') returning *;
┌────────────┬────────────────┬──────────────┬─────────────┬───────────┬────────────────┬───────────────┬───────────────────────────────┬───────────────┬───────────────┐
│ service_id │ service_name │ service_type │ description │ is_active │ is_synthesized │ data_ins_user │ data_ins_date │ data_upd_user │ data_upd_date │
├────────────┼────────────────┼──────────────┼─────────────┼───────────┼────────────────┼───────────────┼───────────────────────────────┼───────────────┼───────────────┤
│ 2641 │ testinator-api │ network │ │ t │ f │ kovert │ 2023-03-01 16:35:13.085194+00 │ │ │
└────────────┴────────────────┴──────────────┴─────────────┴───────────┴────────────────┴───────────────┴───────────────────────────────┴───────────────┴───────────────┘
(1 row)
- There are a number of ways to get the key into the database and vault. The documentation for signkeymgr contains various ways, but this is an example that uses Kerberos.
[host:1 jazzhands-openid] VAULT_CAPATH=/etc/ssl/certs VAULT_ADDR=https://vault.example.org:8200/ save --generate --vaultpath kv/data/minter/testinator --dbhost cmdbhost --database jazzhands --service testinator-api
[host:2 jazzhands-openid]
- Add a service version for the underlying service. This ideally matches the tag of a release:
user@cmdbhost:cmdb> INSERT INTO service_version (service_id, service_version_name)
SELECT service_id, '0.99'
FROM service
WHERE service_type = 'network'
AND service_name IN ('testinator-api') returning *;
┌────────────────────┬────────────┬──────────────┬──────────────────────┬────────────┬───────────────┬────────────────┬───────────────┬──────────────────────────────┬───────────────┬───────────────┐
│ service_version_id │ service_id │ service_type │ service_version_name │ is_enabled │ is_deprecated │ is_synthesized │ data_ins_user │ data_ins_date │ data_upd_user │ data_upd_date │
├────────────────────┼────────────┼──────────────┼──────────────────────┼────────────┼───────────────┼────────────────┼───────────────┼──────────────────────────────┼───────────────┼───────────────┤
│ 4 │ 2641 │ network │ 0.99 │ t │ f │ f │ kovert │ 2023-03-01 16:35:41.08424+00 │ │ │
└────────────────────┴────────────┴──────────────┴──────────────────────┴────────────┴───────────────┴────────────────┴───────────────┴──────────────────────────────┴───────────────┴───────────────┘
(1 row)
INSERT 0 1
- Setup a service endpoint. This example assumes a DNS record already exists for testinator.example.org (columns in
dns_record
anddns_domain
):
INSERT INTO service_endpoint (
service_id, dns_record_id, port_range_id, service_endpoint_uri_fragment
) SELECT service_id, dns_record_Id, port_range_id, 'testinator/v1'
FROM service, port_range,
(SELECT dns_record_id
FROM dns_record
JOIN dns_domain USING (dns_domain_id)
WHERE dns_name = 'api'
AND dns_domain_name = 'example.org'
) x
WHERE service_type = 'network' AND service_name = 'testinator-api'
AND port_range_type = 'services' AND port_range_name = 'https'
;
4, The end point needs to be attached to actual devices running the service. There's a number of ways to do this but to anchor it to one host, something like this:
SELECT service_manip.direct_connect_endpoint_to_device(
device_id := device_id,
service_version_id := service_version_id,
service_endpoint_id := service_endpoint_id,
service_environment_id := service_environment_id
) FROM device, service_endpoint JOIN (
SELECT service_id, service_version_id
FROM service_version sv JOIN service USING (service_id), service_environment
WHERE service_name IN ('testinator-api')
AND sv.service_type = 'network'
AND service_version_name = '0.99'
AND service_environment_type = 'default'
AND service_environment_name = 'production'
) sv USING (service_id)
WHERE device_name ~ '01.api.example.org'
AND service_endpoint_id IN (
SELECT service_endpoint_id
FROM service_endpoint
WHERE service_id IN (
SELECT service_id
FROM service
WHERE service_type = 'network'
AND service_name IN ('testinator-api')
)
)
- Likely all access should be granted into an account collection type, and it probably should be openid, but create the account collection type:
INSERT INTO val_account_collection_type ( account_collection_type) VALUES ( 'openid');
- At this point, no users have access to get tokens, so that access will need to be granted. Map a user to the negotiate method replace
negotiate
withpassword
to allow username/password):
WITH ac AS (
INSERT INTO account_collection (
account_collection_name, account_collection_type
) VALUES (
'testinator-api-negotiate', 'openid'
) RETURNING *
) INSERT INTO property (
property_type, property_name, service_version_collection_id,
account_collection_id, property_value_json
) SELECT 'jazzhands-openid', 'authentication-rules', service_version_collection_id,
account_collection_id, '{"method":"negotiate", "max_token_lifetime": 900 }'
FROM ac, service_version_collection
WHERE service_version_collection_name = 'testinator-api'
AND service_version_collection_type = 'current-services';
- Add membership to that account collection:
INSERT INTO account_collection_hier (
account_collection_id, child_account_collection_id
) SELECT p.account_collection_id, c.account_collection_id
FROM (
SELECT *
FROM account_collection
WHERE account_collection_type = 'openid'
AND account_collection_name = 'testinator-api-negotiate'
) p, (
SELECT *
FROM account_collection
WHERE account_collection_type = 'team'
AND account_collection_name IN (
'Some Team'
)
) c;
This installs the minter web service in a docker container. It needs to run under apache in order for Kerberos/negotiate to work or some other browser that has an implementation. It was untested under anything else. The expectation is that there will probably be a front end that forwards into the container, although this is not strictly necessary.
- The Dockerfile in the minter directory builds a container that can be installed and configured.
- The docker-compose.yaml file describes how to set it up.
-
Volumes mounted from outside onto paths inside the container:
/etc/keytab.www
- keytab with HTTP/fqdn for the possible ways the service can be connected to. This is used for any users (and devices`) that use Kerberos to authenticate/var/lib/jazzhands/appauthal-info/jazzhands-oauth-jwt-minter.json
- DBAAL file for theapp_jazzhands_openid_jwt_minter
connection setup in the next steps./vault
- contains files, role-id and secret-id that are refreshed periodically to allow for connecting to vault to retrieve private keys (or anything else)/etc/ssl/certs
- where various things expect trusted certificates to live for validating any https connections
-
Environment Variables
- APPAUTHAL_CONFIG can be a path to a config file to override the default place for appauthal files
- VAULT_ADDR can be used to define where vault is, if it's not in the dbaal file
- VAULT_CAPATH can be used to define where to look for the CA for connecting to vault, if it is not in the dbaal file
- There are other variables used by the underlying tools and libraries that can be tweaked
-
- For apache. The /token endpoint will take care of redirecting to the
/n/
endpoint if Kerberos/GSSAPI/Negotiate is required for the user. The/n
endpoint is just protected by Kerberos. If Kerberos is not used, then the first stanza is needed.
ProxyPass /token/ http://127.0.0.1:8080/token/
ProxyPassReverse /token/ http://127.0.0.1:8080/token/
ProxyPass /n/ http://127.0.0.1:8080/n/
ProxyPassReverse /n/ http://127.0.0.1:8080/n/
- Nginx would be similar. As with apache
/n
is only required if kerberos/negotiate is in play.
location ~ ^(/token/|/n/) {
proxy_pass http://127.0.0.1:8080/$1;
proxy_read_timeout 90s;
proxy_connect_timeout 90s;
proxy_send_timeout 90s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
The following properties (property_type:property_name) are used to define how access works:
service_version_collection_id
is set for all of them, typically to the
collection of type current_services
named after the service.
Default lifetime for a bearer token is 3600 seconds (one hour).
- property jazzhands-openid:jwt-signer - set by
signkeymgr.pl
.service_version_collection_id
(as above)property_value_private_key
- property
jazzhands-openid:authentication-rules
service_version_collection_id
(as above)account_collection_id
- who it applies toproperty_value_json
- with the following keys, only method is required:- method can be
negotiate
orpassword
. Negotiate will force Kerberos using theclient_credentials
type,password
will look up the user's blowfish password inaccount_password
- prefix, optional, is the database username that should be prepended to the user as the user to act as. Such as "pgrst_." When left out, the user's role is assumed.
- suffix, optional, is the database username that should be appended to the user as the user to act as. Such as "_pgrst." When left out, the user's role is assumed.
- max_token_lifetime - optional, number of seconds a token can be issued for to the user
- method can be
- property
jazzhands-openid:permit-device-authentication
- defines what devices are allowed to authenticate to the API using their Kerberos principal and what role they are to assumeservice_version_collection_id
(as above)device_collection_id
- devices that are allows to authenticateproperty_value_json
, with the following keys, only role is required:- role: role to assume for this device
- max_token_lifetime - optional, number of seconds a token can be issued for to the user
- property
jazzhands-openid:act-as-device
- allow humans to authenticate as themselves but act on behalf of a deviceservice_version_collection_id
(as above)account_collection_id
- who is allowed to do thisdevice_collection_id
- devices that are allows to be authenticated
- property
jazzhands-openid:act-as-account
and related. This is kind of gross, but is multiple properties that act in concert with each other. This will hopefully eventually be replaced with one uber property when json+fk support is better:- property
jazzhands-openid:act-as-account
- allow an account to assume the permissions of a different accountservice_version_collection_id
(as above)account_collection_id
- who is allowed to do thisproperty_value_account_collection_id
- whose identity they can assume
- property
jazzhands-openid:act-as-account-prefix
- prefix, as withauthentication-rules
service_version_collection_id
(as above)account_collection_id
- who is allowed to do thisproperty_value
- prefix to use
- property
jazzhands-openid:act-as-account-suffix
- suffix, as withauthentication-rules
service_version_collection_id
(as above)account_collection_id
- who is allowed to do thisproperty_value
- suffix to use
- property
This was originally created to support generating JWTs for use with PostgREST to provide an API interface to obtaining a JWT for use with it and in the interest of standards compliance, became a generic JWT issuer and ultimately something that can be used by anything that wants to hook into OpenID connect.
It is, however, primarily to support PostgREST instances, so there's some hard coded nuances related to that, but the goal is to move away from that as much as possible and make that just another use case.
This module provides all the components to setup an endpoint and manage various ways for users to obtain a JWT.
This just takes care of authenticating a user and setting up appropriate access, be that a JWT to be used as a bearer token for access or, eventually, the code/token dance for an end user to authenticate to a service.
The general interface is kind of clunky and probably needs a PostgREST interface to make better, which would lead to a chicken/egg thing, but that's life.
This support provides an OAuth2/OpenID-Connect style web interface for issuing limited life JWTs that are used as bearer tokens. They are issued and then forgotten, so it is not possible to preemptively expire them, thus limited lifetime. OAuth2 has "scopes" which are named after service endpoints.
The JWT is encoded as a bearer token which is really a base64 encoded signed JWT to authenticate users. The JWT contains various pieces about the user that will be used underneath and tells how to authenticate as the user.
There's very little code in all of this. Most of it is JWT management and conventions.
The database schema has a handful of views that present all the various scenarios:
- authentication_rules - This specifies the methods a user can use to login and how to translate their name. The current supported methods are negotiate and password. In the future, it may be desire to add TLS authentication
- permitted_account_impersonation - Maps services to users that can impersonate other users (login on behalf of) and what their assumed role should be when signing in. The human still needs to be able to login based on authentication_rules but the role assumed is the one assumed. That means that a user does not need to be able to login directly in order to have their identity assumed.
- permitted_device_impersonation - services/user/device mapping for which humans are allowed to simulate which devices. This uses the role mapping defined in service_device_permitted_authentication for what role to assumed.
- service_device_permitted_authentication - service/device mappings and the database role that should be assumed (using host/fqdn Kerberos principals, typically but they could also use user/pass)
- service_jwt_signing_keys - given a service, the location (ideally in vault) of the key used to sign JWTs for the services.
Most of these are views on top of the property
, account_collection
,
and service_version_collection
and related tables.
minter is a web service that issues JWTs using an OAuth2/OpenID-Connect style compatible interface. It takes a JSON blob as input and returns a JSON blob with errors or a bearer token and related data.
Two types of oauth2 methods for login are supported, "client_credentials" and "password". In the "client_credentials" case, the web server handles the authentication and passes the identity down. The only client_credentials case supported is negotiate, which is basically a way of doing GSSAPI/Kerberos with web interfaces. It would be possible to extend this to include client TLS or other methods. Basic AUTH (back ended with LDAP) would not make any sense since oauth2 provides a username/password method.
For password authentication, the account_password_manip.authenticate_account
function is used to authenticate a user, wrapping the password in the
obfuscation_tools
library so passwords don't leak in function arguments if
query logging is turned up a lot. This means that the password used for the
user is the one in the JazzHands account_password
table and NOT a database
user password, despite the user having an account. In general the user used
for these logs should be separate than a direct user login.
The permitted methods are stored in authentication_rules
as well as
optional translations from the underlying username to the role to sign into the
database as. This means that humans that have general use accounts with
broad permissions that do not overlap "api" accounts. This extra layer of
indirection can be excessive for pseudousers that only connect via the API
so it is not required/recommended.
Note that if a user attempts "client_credentials" and the username and password are passed in, the authentication will outright fail in order to prevent credentials leak.
If a user is setup to use the negotiate extension, the minter itself
takes care of sending back a WWW-Authenticate: Negotiate
header which
should kick off GSSAPI if the client supports it, re-invoking minter
after the web server has processed the headers and authenticated the
user. It does a "look ahead" at what was posted to determine if this
should happen and assumes the client will POST the same payload after
authentication starts (which typically happens). Adding client TLS
support would require figuring out how to deal with this look ahead, if
possible (it may just outright fail).
An https POST of type application/x-www-form-urlencoded using the following values:
grant_type => 'client_credentials or password',
username => 'unmapped username',
password => 'user password',
nonce => 'some nonce'
scope => 'https://myapi.example.com/api/v1',
on_behalf_of => 'someone else',
It is also possible to set on_behalf_of to host/fqdn
, which will cause
device impersonation to take place, if permitted.
The minimum required are grant_type
, nonce
, and scope
. If grant_type
is
password
, then username
and password
are also required.
A successful response if code 200 includes a json response of:
{
"scope": "https://myapi.example.com/api/v1"",
"token_type": "Bearer",
"access_token": "base64string",
"expires_in": "900"
}
The only token type currently supported is Bearer
, and any other value
should cause the client to error out. The value of access_token
should
be passed into the requests to the PostgREST endpoints:
Authorization: Bearer $access_token
When using with PostgREST, the The scope becomes the audience inside the bearer token, which is validated by PostgREST.
Error responses should be Consistent with the OAuth spec.
Failures will be 400s and the body will always be a json message
with a specific error
field and a human readable error_description
value and type 400. A non-400 response (besides the 401 Unauthorized to
trigger the GSSAPI/Negotiate dance) means there is a problem outside minter
causing things to fail. Database connection errors and other transient
errors, will show up as 400s with temporary_unavailable
error codes, and there
will be more information for the operator in the error output of the
minter process (typically the apache logs or the container foreground
logs).
It is possible to map JazzHands accounts to database users 1:1 with the same
name. It is also possible to setup prefixes and suffixes for the user that
gets the role set. For example, given a prefix of pgst_, if user kovert
signed in as themselves, they would be mapped to the pgrst_kovert
. This can
be assigned on a per-account collection basis, so it is possible to have one
policy assigned to humans, who may have different account permissions and
another to pseudousers.
It is possible to allow users to assume the identity of other users if they are marked as such. In that case, it is possible to set a prefix and suffix for all users setup this way. This will hopefully be addressed in a future version so it is as granular as the per-individual system.
The possible values that minter may include are as follows (with a note to when they are part of the JWT):
{
"ist" : epoch, # time_t when issued, always
"sub" : "who to log", # subject, humans, always
"jti" : "UUID", # correlate to logs, always
"act" : { # only if issued on behalf
"sub" : "kovert" # who asked for it
},
"aud" : "URL", # scope/audience, URL, always
"role" : "who to become", # PostgREST assumes this, always
"iss" : "4b3a867cabeb_oauthminter", # issuer id (hostname, etc), always
"exp" : epoch, # when expired, always
"nbf" : epoch # when issued, always
}
"role" is "sub" passed through the aforementioned mapping processes. This is
the database user that is ultimately SET LOCAL ROLE
d to. This will
eventually be configurable per-service.
"sub" is a JazzHands account
. The "act" section only exists if
the JWT was grabbed on behalf of another account and would be the user that
authenticated to get the JWT (unmapped). IF there is not "act" section, then
the subject
There is no support for refresh tokens at this time. Document:
- how errors look and what responses look like.
- on_behalf_of to the request (I think) and device_id to the JWT.
minter users the functions within the JazzHands::Common::Logging module to log successful authorizations, not permitted authentications. and some fatal errors. Some are logged to stderr.
The default container will log everything to stderr, and log interesting authorization related things to the LOG_AUTH facility. If /dev/log is mounted inside the container, it will log to the local host.
The /etc/jazzhands/minter_logging.cfg
file inside the container can be
replaced and trigger logging to go elsewhere. Those details can be found
in the JazzHands::Common::Logging module (those are the arguments to the
initialization function) but are generally left as an exercise for the reader.
There need to be some stored procedures for the generic part of things.
This implementation depends on certain service model to exist for the api. This may be inside another service. Each service uses a private key to sign JWTs that are scoped to the API. It is possible for services to share the key and for one JWT to handle multiple scopes/audiences although minter does not support this at this time.
The following need to exist:
- A
service
row for the service itself - At least one
service_version
for the service. Ideally this is something that gets updated over time but it's possible to have just a stub for all versions. - In the case that a given services is hosted on another one, an entry in
service_relationship
- A
private_key
to sign JWTs and aproperty
that ties it aservice_version_collection
(typically one of the auto created ones) account_collection
s , andproperty
s grant user(s) the ability to login. Logically, this would be a type per ecosystem, such as typePostgREST-support
although that is not required.