Skip to content

Commit

Permalink
Fix protocol issues in OpenID Connect server
Browse files Browse the repository at this point in the history
Stop exposing all known claims by default in the issued ID token
and instead honor scopes requested by the client. Currently, the
supported scopes include the profile and email scopes defined by
OpenID Connect (insofar as Gafaelfawr has the data).

Set the aud claim on ID tokens to the client ID rather than a fixed
audience value that matches the Gafaelfawr issuer, bringing the
implementation in line with the intent of the specification.

Tie the expiration time of OpenID Connect ID tokens to the expiration
of the underlying Gafaelfawr token used as an authentication basis.

Require the oidcServer.issuer configuration setting use the https
scheme, since the protocol requires that. Include the scope that was
used for ID token issuance in the response from the token endpoint,
since the spec may require that if unknown scopes were requested.

Declare, in the OpenID configuration endpoint, that the only
supported response mode is query.
  • Loading branch information
rra committed Jan 10, 2024
1 parent d690802 commit 0384a8f
Show file tree
Hide file tree
Showing 14 changed files with 428 additions and 185 deletions.
11 changes: 11 additions & 0 deletions changelog.d/20240110_125829_rra_DM_42384.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
### Backwards-incompatible changes

- When acting as an OpenID Connect server, Gafaelfawr no longer exposes all claims by default. Instead, it now honors the `scope` parameter in the request, which must include `openid` and may include `profile` and `email`.
- Require the `oidcServer.issuer` configuration setting use the `https` scheme, since this is required by the OpenID Connect 1.0 specification.
- Set the `aud` claim in OpenID Connect ID tokens issued by Gafaelfawr to the client ID of the requesting client instead of a fixed audience used for all tokens.
- OpenID Connect ID tokens issued by Gafaelfawr now inherit their expiration time from the underlying Gafaelfawr token used as the authentication basis for the ID token. Previously, OpenID Connect ID tokens would receive the full default lifetime even when issued on the basis of Gafaelfawr tokens that were about to expire.

### Bug fixes

- Include the scope used to issue the ID token in the reply from the OpenID Connect server token endpoint.
- In the response from `/.well-known/openid-configuration`, declare that the only supported response mode of the OpenID Connect server is `query`.
58 changes: 41 additions & 17 deletions docs/user-guide/openid-connect.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,52 @@
Configuring OpenID Connect
##########################

Basic configuration
===================
Configure Gafaelfawr
====================

To protect a service that uses OpenID Connect, first set ``oidc_server.enabled`` to true in the :ref:`helm-settings`.
Then, create (or add to, if already existing) an ``oidc-server-secrets`` Vault secret key.
The value of the key must be a JSON list, with each list member representing one OpenID Connect client.
To protect a service that uses OpenID Connect, first set ``oidcServer.enabled`` to true in the :ref:`helm-settings`.
Then, create (or add to, if already existing) an ``oidc-server-secrets`` secret for the ``gafaelfawr`` Phalanx application.

The value of the secret must be a JSON list, with each list member representing one OpenID Connect client.
Each list member must be an object with two keys: ``id`` and ``secret``.
``id`` can be anything informative that you want to use to uniquely represent this OpenID Connect client.
``id`` is the unique OpenID Connect client ID that the client will present during authentication.
``secret`` should be a randomly-generated secret that the client will use to authenticate.

Then, configure the client.
The authorization endpoint is ``/auth/openid/login``.
The token endpoint is ``/auth/openid/token``.
The userinfo endpoint is ``/auth/openid/userinfo``.
The JWKS endpoint is ``/.well-known/jwks.json``.
As with any other protected service, the client must run on the same URL host as Gafaelfawr, and these endpoints are all at that shared host (and should be specified using ``https``).
Configure the OpenID client
===========================

Gafaelfawr exposes the standard OpenID Connect configuration information at ``/.well-known/openid-configuration``.
Clients that can auto-discover their configuration from that may only need to be configured with the client ID and secret matching the Gafaelfawr configuration.

For clients that require more manual configuration, the OpenID Connect routes are:

- Authorization endpoint: ``/auth/openid/login``.
- Token endpoint: ``/auth/openid/token``.
- userinfo endpoint: ``/auth/openid/userinfo``.
- JWKS endpoint: ``/.well-known/jwks.json``.

As with any other protected service, the client must run on the same URL host as Gafaelfawr.
These endpoints are all at that shared host (and should be specified using ``https``).

The client must use the authentication code OpenID Connect flow (see `OpenID Connect Core 1.0 section 3.1 <https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth>`__).
The other authentication flows are not supported.

OpenID scopes
-------------

The following OpenID Connect scopes are supported and influence what claims are included in the ID token:

``openid``
Required, per the OpenID Connect specification.
The standard OAuth 2.0 and OpenID Connect claims will be included, as well as ``scope`` and ``sub``.
For the Gafaelfawr OpenID Connect provider, ``sub`` will always be the user's username.

The OpenID Connect client should be configured to request only the ``openid`` scope.
No other scope is supported.
The client must be able to authenticate by sending a ``client_secret`` parameter in the request to the token endpoint.
``profile``
Adds ``preferred_username``, with the same value as ``sub``, and, if this information is available, ``name``.
Gafaelfawr by design does not support attempting to break the name into components such as given name or family name.

The JWT returned by the Gafaelfawr OpenID Connect server will include the authenticated username in the ``sub`` and ``preferred_username`` claims, and the numeric UID in the ``uid_number`` claim.
``email``
Adds the ``email`` claim if the user's email address is known.

Examples
========
Expand All @@ -50,7 +74,7 @@ Assuming that Gafaelfawr and Chronograf are deployed on the host ``example.com``
``GENERIC_CLIENT_ID`` and ``GENERIC_CLIENT_SECRET`` should match a client ID and secret configured in the ``oidc-server-secrets`` Vault key.

Be aware that this uses the ``sub`` token claim, which corresponds to the user's username, for authentication, rather than the default of the user's email address.
(Gafaelfawr does not always have an email address for a user.)
Gafaelfawr does not always have an email address for a user.

Open Distro for Elasticsearch
-----------------------------
Expand Down
2 changes: 1 addition & 1 deletion examples/docker/gafaelfawr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ github:

# Configuration for the Gafaelfawr OpenID Connect server.
oidc_server:
issuer: "http://localhost:8080"
issuer: "https://localhost:8080"
keyId: "localhost-key-id"
audience: "http://localhost"
keyFile: "/run/secrets/issuer-key"
Expand Down
2 changes: 1 addition & 1 deletion examples/gafaelfawr-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ github:

# Configuration for the Gafaelfawr OpenID Connect server.
oidcServer:
issuer: "http://localhost:8080"
issuer: "https://localhost:8080"
keyId: "localhost-key-id"
audience: "http://localhost"
keyFile: "examples/secrets/issuer-key"
Expand Down
23 changes: 13 additions & 10 deletions src/gafaelfawr/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,19 @@
from datetime import timedelta
from ipaddress import IPv4Network, IPv6Network
from pathlib import Path
from typing import Self
from typing import Annotated, Self
from uuid import UUID

import yaml
from pydantic import (
AnyHttpUrl,
BaseModel,
Field,
UrlConstraints,
field_validator,
model_validator,
)
from pydantic_core import Url
from safir.logging import LogLevel, Profile, configure_logging
from safir.pydantic import CamelCaseModel, validate_exactly_one_of

Expand All @@ -46,6 +48,7 @@
"GitHubGroup",
"GitHubGroupTeam",
"GitHubSettings",
"HttpsUrl",
"LDAPConfig",
"LDAPSettings",
"NotebookQuota",
Expand All @@ -62,6 +65,13 @@
"Settings",
]

HttpsUrl = Annotated[
Url,
UrlConstraints(
allowed_schemes=["https"], host_required=True, max_length=2083
),
]


class GitHubSettings(CamelCaseModel):
"""pydantic model of GitHub configuration."""
Expand Down Expand Up @@ -299,15 +309,12 @@ class FirestoreSettings(CamelCaseModel):
class OIDCServerSettings(CamelCaseModel):
"""pydantic model of issuer configuration."""

issuer: str
issuer: HttpsUrl
"""iss (issuer) field in issued tokens."""

key_id: str
"""kid (key ID) header field in issued tokens."""

audience: str
"""aud (audience) field in issued tokens."""

key_file: Path
"""File containing RSA private key for signing issued tokens."""

Expand Down Expand Up @@ -745,9 +752,6 @@ class OIDCServerConfig:
key_id: str
"""kid (key ID) header field in issued tokens."""

audience: str
"""aud (audience) field in issued tokens."""

keypair: RSAKeyPair
"""RSA key pair for signing and verifying issued tokens."""

Expand Down Expand Up @@ -1001,9 +1005,8 @@ def from_file(cls, path: Path) -> Self: # noqa: PLR0912,PLR0915,C901
for c in oidc_secrets
)
oidc_server_config = OIDCServerConfig(
issuer=settings.oidc_server.issuer,
issuer=str(settings.oidc_server.issuer),
key_id=settings.oidc_server.key_id,
audience=settings.oidc_server.audience,
keypair=oidc_keypair,
lifetime=timedelta(minutes=settings.token_lifetime_minutes),
clients=oidc_clients,
Expand Down
2 changes: 2 additions & 0 deletions src/gafaelfawr/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,11 +415,13 @@ def create_oidc_service(self) -> OIDCService:
)
authorization_store = OIDCAuthorizationStore(storage)
token_service = self.create_token_service()
user_info_service = self.create_user_info_service()
slack_client = self.create_slack_client()
return OIDCService(
config=self._context.config.oidc_server,
authorization_store=authorization_store,
token_service=token_service,
user_info_service=user_info_service,
slack_client=slack_client,
logger=self._logger,
)
Expand Down
Loading

0 comments on commit 0384a8f

Please sign in to comment.