Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add modified OIDC authenticator #841

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
36 changes: 15 additions & 21 deletions docs/source/explanations/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,44 +238,38 @@ pip install httpx
3. It is recommended to set the Client Secret as an environment variable, such
as `OIDC_CLIENT_SECRET`, and reference that from configuration file as shown
below.
4. Obtain the OIDC provider's public key(s). These are published by the OIDC provider.
Starting from a URL like:
4. Get the OIDC provider's well-known endpoint. These are expected shared configuration values published by the OIDC provider.
danielballan marked this conversation as resolved.
Show resolved Hide resolved
Typically it is a URL like:

* [https://accounts.google.com/.well-known/openid-configuration](https://accounts.google.com/.well-known/openid-configuration)
* [https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration](https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration)
* [https://orcid.org/.well-known/openid-configuration](https://orcid.org/.well-known/openid-configuration)

Navigate to the link under the key `jwks_uri`. These public key(s) are designed
to prevent man-in-the-middle attacks. They may be rotated over time.

The configuration file(s) must include the following.

```yaml
authentication:
providers:
- provider: SOME_NAME_HERE
- provider: example.com
authenticator: tiled.authenticators:OIDCAuthenticator
args:
# All of these are given by the OIDC provider you register
# your application.
client_id: ...
client_secret: ${OIDC_CLIENT_SECRET} # reference an environment variable
# These come from the OIDC provider as described above.
token_uri: ...
authorization_endpoint: ...
public_keys:
- kty: ...
e: ...
use: ...
kid: ...
n: ...
alg: ...
confirmation_message: "You have logged in with ... as {id}."
# Values should come from your OIDC provider configuration
# The audience claim is checked by the OIDC Client (Tiled)
# It checks that the Authentication header that you are passed has not been intercepted
# And that elevated claims from other services do not apply here
audience: tiled # something unique to ensure received headers are for you
client_id: tiled_client
client_secret: ${OIDC_CLIENT_SECRET} # referencing an environment variable
well_known_uri: example.com/.well-known/openid-configuration
```

There are example configurations for ORCID and Google in the directory
`example_configs/` in the Tiled source repository.

You can also try it against a locally-runing dummy OIDC provider. See the
README and files under `example_configs/simple_oidc/` in the Tiled source
repository.

### Toy examples for testing and development

The ``DictionaryAuthenticator`` authenticates using usernames and passwords
Expand Down
19 changes: 2 additions & 17 deletions example_configs/google_auth.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,11 @@ authentication:
- provider: google
authenticator: tiled.authenticators:OIDCAuthenticator
args:
audience: tiled # something unique to ensure received headers are for you
# These values come from https://console.cloud.google.com/apis/credential
client_id: ${GOOGLE_CLIENT_ID}
client_secret: ${GOOGLE_CLIENT_SECRET}
# These values come from https://accounts.google.com/.well-known/openid-configuration
# Obtain them directly from Google. They may change over time.
token_uri: "https://oauth2.googleapis.com/token"
authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth"
public_keys:
- alg: RS256
e: AQAB
kid: ee1b9f88cfe3151ddd284a61bf8cecf659b130cf
kty: RSA
n: rTOxVQCdPMM6n3XRW7VW5e8bGCoimxT-m4cUyaTtLCIf1IqFJRhzc3rgdxsdpg5fjj1Ln2yG_r-3FbkFYJw1ebOCwJ_xlrIeL7FZWqKHl2u5tPKhYkBpPsh-SFZrlEv6X6W2tLcXaFs_8qeHbEasW3A7S6SiS6vMLvcEgufvHSHM1W61U6R9wzOo0lr3rBBOahZFr2Vym8P3eZZ9u_i07RFEqUEFhHXnHYHMLY2Ch9-JbZlCRVbBOfTxCPdOqOkZyFQfGOMj5XLbPHXLSBlmsNzFSv3KgPhZgvmfK113VUN3RFgnDZ5q_-4FK82j_L0FrYZUPRGBA9Crlvtxg_LJWQ
use: sig
- alg: RS256
e: AQAB
kid: 77cc0ef4c7181cf4c0dcef7b60ae28cc9022c76b
kty: RSA
n: yCR1Za9HjpT49GymRQlYSsNg8z7PZGFh5a26IaCo86xPuAcf6VumrKYG6aK9Y1Bh9qJ9MBV1oajmatTuXtc-FtqwqH9Jzbb_-mCYGylx08Mqr83ydV_fIa64ilpVlBz_LHDeDKIYNepQLGqlMNQ6iVuM9MX9NesN3_twudqgz_Ll3FZkpi0DsVOIwV-fOP3zH6h_e0YPbIIjIcxCUs3Pe0rkcjUVRf3yDfPQTjaNtUh9Qg6DGIi1xe5DU0egLvQv6CdbR3wMxNDp8unhForCaenlD8ulzB_tZT0ft6uxPOHEx29FpH6mzfIsbcTZ7VaBfw6KYUaPsZOCcspY14exow
use: sig
well_known_uri: https://accounts.google.com/.well-known/openid-configuration
confirmation_message: "You have logged in with Google as {id}."
trees:
# Just some arbitrary example data...
Expand Down
13 changes: 2 additions & 11 deletions example_configs/orcid_auth.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,11 @@ authentication:
- provider: orcid
authenticator: tiled.authenticators:OIDCAuthenticator
args:
audience: tiled # something unique to ensure received headers are for you
# These values come from https://orcid.org/developer-tools
client_id: ${ORCID_CLIENT_ID}
client_secret: ${ORCID_CLIENT_SECRET}
# These values come from https://orcid.org/.well-known/openid-configuration
# Obtain them directly from ORCID. They may change over time.
token_uri: "https://orcid.org/oauth/token"
authorization_endpoint: "https://orcid.org/oauth/authorize"
public_keys:
- kty: "RSA"
e: "AQAB"
use: "sig"
kid: "production-orcid-org-7hdmdswarosg3gjujo8agwtazgkp1ojs"
n: "jxTIntA7YvdfnYkLSN4wk__E2zf_wbb0SV_HLHFvh6a9ENVRD1_rHK0EijlBzikb-1rgDQihJETcgBLsMoZVQqGj8fDUUuxnVHsuGav_bf41PA7E_58HXKPrB2C0cON41f7K3o9TStKpVJOSXBrRWURmNQ64qnSSryn1nCxMzXpaw7VUo409ohybbvN6ngxVy4QR2NCC7Fr0QVdtapxD7zdlwx6lEwGemuqs_oG5oDtrRuRgeOHmRps2R6gG5oc-JqVMrVRv6F9h4ja3UgxCDBQjOVT1BFPWmMHnHCsVYLqbbXkZUfvP2sO1dJiYd_zrQhi-FtNth9qrLLv3gkgtwQ"
alg: RS256
well_known_uri: https://orcid.org/.well-known/openid-configuration
confirmation_message: "You have logged in with ORCID as {id}."
trees:
# Just some arbitrary example data...
Expand Down
13 changes: 2 additions & 11 deletions example_configs/simple_oidc/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,10 @@ authentication:
- provider: simple_oidc
authenticator: tiled.authenticators:OIDCAuthenticator
args:
# These values come from https://orcid.org/developer-tools
audience: ${OIDC_CLIENT_ID}
client_id: ${OIDC_CLIENT_ID}
client_secret: ${OIDC_CLIENT_SECRET}
# These values come from https://orcid.org/.well-known/openid-configuration
# Obtain them directly from ORCID. They may change over time.
token_uri: "${OIDC_BASE_URL}/token"
authorization_endpoint: "${OIDC_BASE_URL}/auth"
public_keys:
- kty: "RSA"
e: "AQAB"
kid: "<Enter kid value from simple oidc web page http://localhost:9000/certs>"
n: "<Enger n from simple oidc web page http://localhost:9000/certs>"
alg: RS256
well_known_uri: "${OIDC_BASE_URL}/.well-known/openid-configuration"
confirmation_message: "You have logged in with Simple OIDC as {id}."
trees:
# Just some arbitrary example data...
Expand Down
139 changes: 62 additions & 77 deletions tiled/authenticators.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
import re
import secrets
from collections.abc import Iterable
from typing import Any, cast

import httpx
from fastapi import APIRouter, Request
from jose import JWTError, jwk, jwt
from jose import JWTError, jwt
from pydantic import Secret
from starlette.responses import RedirectResponse

from .server.authentication import Mode
Expand Down Expand Up @@ -115,67 +117,79 @@ class OIDCAuthenticator:
type: object
additionalProperties: false
properties:
audience:
type: string
client_id:
type: string
client_secret:
type: string
token_uri:
type: string
authorization_endpoint:
well_known_uri:
type: string
public_keys:
type: array
item:
type: object
properties:
- alg:
type: string
- e
type: string
- kid
type: string
- kty
type: string
- n
type: string
- use
type: string
required:
- alg
- e
- kid
- kty
- n
- use
confirmation_message:
type: string
description: May be displayed by client after successful login.
"""

def __init__(
self,
client_id,
client_secret,
public_keys,
token_uri,
authorization_endpoint,
confirmation_message,
audience: str,
client_id: str,
client_secret: str,
well_known_uri: str,
confirmation_message: str = "",
):
self.client_id = client_id
self.client_secret = client_secret
self._audience = audience
self._client_id = client_id
self._client_secret = Secret(client_secret)
self._well_known_url = well_known_uri
self.confirmation_message = confirmation_message
self.public_keys = public_keys
self.token_uri = token_uri
self.authorization_endpoint = httpx.URL(authorization_endpoint)

async def authenticate(self, request) -> UserSessionState:
@functools.cached_property
def _config_from_oidc_url(self) -> dict[str, Any]:
response: httpx.Response = httpx.get(self._well_known_url)
response.raise_for_status()
return response.json()

@functools.cached_property
def client_id(self) -> str:
return self._client_id

@functools.cached_property
def id_token_signing_alg_values_supported(self) -> list[str]:
return cast(
list[str],
self._config_from_oidc_url.get("id_token_signing_alg_values_supported"),
)

@functools.cached_property
def issuer(self) -> str:
return cast(str, self._config_from_oidc_url.get("issuer"))

@functools.cached_property
def jwks_uri(self) -> str:
return cast(str, self._config_from_oidc_url.get("jwks_uri"))

@functools.cached_property
def token_endpoint(self) -> str:
return cast(str, self._config_from_oidc_url.get("token_endpoint"))

@functools.cached_property
def authorization_endpoint(self) -> httpx.URL:
return httpx.URL(
cast(str, self._config_from_oidc_url.get("authorization_endpoint"))
)

async def authenticate(self, request: Request) -> UserSessionState:
code = request.query_params["code"]
# A proxy in the middle may make the request into something like
# 'http://localhost:8000/...' so we fix the first part but keep
# the original URI path.
redirect_uri = f"{get_root_url(request)}{request.url.path}"
response = await exchange_code(
self.token_uri, code, self.client_id, self.client_secret, redirect_uri
self.token_endpoint,
code,
self._client_id,
self._client_secret.get_secret_value(),
redirect_uri,
)
response_body = response.json()
if response.is_error:
Expand All @@ -184,11 +198,14 @@ async def authenticate(self, request) -> UserSessionState:
response_body = response.json()
id_token = response_body["id_token"]
access_token = response_body["access_token"]
# Match the kid in id_token to a key in the list of public_keys.
key = find_key(id_token, self.public_keys)
keys = httpx.get(self.jwks_uri).raise_for_status().json().get("keys", [])
try:
verified_body = jwt.decode(
id_token, key, access_token=access_token, audience=self.client_id
token=id_token,
key=keys,
algorithms=self.id_token_signing_alg_values_supported,
audience=self._audience,
access_token=access_token,
)
except JWTError:
logger.exception(
Expand All @@ -203,38 +220,6 @@ class KeyNotFoundError(Exception):
pass


def find_key(token, keys):
"""
Find a key from the configured keys based on the kid claim of the token

Parameters
----------
token : token to search for the kid from
keys: list of keys

Raises
------
KeyNotFoundError:
returned if the token does not have a kid claim

Returns
------
key: found key object
"""

unverified = jwt.get_unverified_header(token)
kid = unverified.get("kid")
if not kid:
raise KeyNotFoundError("No 'kid' in token")

for key in keys:
if key["kid"] == kid:
return jwk.construct(key)
return KeyNotFoundError(
f"Token specifies {kid} but we have {[k['kid'] for k in keys]}"
)


async def exchange_code(token_uri, auth_code, client_id, client_secret, redirect_uri):
"""Method that talks to an IdP to exchange a code for an access_token and/or id_token
Args:
Expand Down
Loading