OpenID Connect (OIDC) is an authentication protocol layered on top of OAuth2, where a relying party (RP) "out-sources" the actual authentication to a trusted OpenID provider (OP) by requesting a limited set of profile information about a user.
In this example we create a docker-compose
application with two services (see docker-compose.yaml):
rp
acts as the relying party (on port 8080) andop
acts as the (mock) OpenID provider (on port 8081).
In the real world, we will probably use an existing OpenID provider somewhere else.
version: "3"
services:
rp:
image: coupergateway/couper
container_name: relying-party
ports:
- 8080:8080
depends_on:
- op
volumes:
- ./relying-party:/conf
environment:
COUPER_WATCH: "true"
RP_CLIENT_ID: "foo"
RP_CLIENT_SECRET: "bar"
op:
image: coupergateway/couper
container_name: openid-provider
ports:
- 8081:8080
volumes:
- ./openid-provider:/conf
environment:
COUPER_WATCH: "true"
The relying party's client ID and client secret are configured with RP_CLIENT_ID
and RP_CLIENT_SECRET
. In the example, these are dummy values. But in real life, we would have got ID and secret after registering a confidential relying party at the OpenID provider.
Now, let's configure Couper as an OpenID relying party (couper.hcl).
We start with the oidc
block within the definitions
:
definitions {
oidc "MyOIDC" {
configuration_url = "http://testop:8080/.well-known/openid-configuration"
client_id = env.RP_CLIENT_ID
client_secret = env.RP_CLIENT_SECRET
redirect_uri = "/oidc/redir"
verifier_value = request.cookies.authvv
}
}
configuration_url
references the OpenID provider's configuration.client_id
is the relying party's client ID defined in theRP_CLIENT_ID
environment variable.client_secret
is the relying party's client secret defined in theRP_CLIENT_SECRET
environment variable.redirect_uri
is the relying party's redirect endpoint registered at the OP.verifier_value
provides a value for the OAuth2 verifier (here: from a cookie namesauthvv
).
In the server
block we configure an endpoint that returns the OIDC authorization URL:
server {
endpoint "/oidc/login" {
response {
headers = {
cache-control = "no-cache,no-store"
set-cookie = "authvv=${oauth2_verifier()};HttpOnly;Secure;Path=/oidc/redir"
}
json_body = {
url = "${oauth2_authorization_url("MyOIDC")}&state=${url_encode(relative_url(request.form_body.url[0]))}"
}
}
}
}
definitions {
# ...
The oauth2_authorization_url()
function creates this URL from information provided in the oidc
block.
The second endpoint is the redirect endpoint receiving the authorization code provided by the OP:
server {
endpoint "/oidc/login" {
# ...
}
endpoint "/oidc/redir" {
access_control = ["MyOIDC"]
response {
status = 303
headers = {
set-cookie = [
"UserToken=${jwt_sign("UserToken", {
sub = request.context.MyOIDC.id_token_claims.sub
name = request.context.MyOIDC.id_token_claims.name
given_name = request.context.MyOIDC.id_token_claims.given_name
family_name = request.context.MyOIDC.id_token_claims.family_name
preferred_username = request.context.MyOIDC.id_token_claims.preferred_username
})};HttpOnly;Secure;Path=/api",
"authvv=;HttpOnly;Secure;Path=/oidc/redir;Max-Age=0"
]
location = relative_url(request.query.state[0])
}
}
}
}
definitions {
...
This endpoint is protected by the oidc
access control, which exchanges the received authorization code with an access token and an ID token and stores some data from the token response in request.context.MyOIDC
. From this information we create a JWT and send it to the browser via the set-cookie
header. The status
code 303
together with the location
header causes the browser to load the current HTML page again.
Our simple example also has a small API which is protected by a jwt
access control configured in the definitions
(see JWT Access Control for more information):
server {
# ...
api {
base_path = "/api"
access_control = ["UserToken"]
}
}
definitions {
jwt "UserToken" {
signature_algorithm = "HS256"
key = "Th3$e(rEt"
cookie = "UserToken"
}
oidc "MyOIDC" {
# ...
}
Because the API is the only consumer of the created tokens, we can use the same jwt
block to configure the jwt_sign
function in the "/oidc/redir"
endpoint by adding a signing_ttl
attribute (see Creating JWT for more information). The created tokens will expire after one hour:
# ...
definitions {
jwt "UserToken" {
signature_algorithm = "HS256"
key = "Th3$e(rEt"
cookie = "UserToken"
signing_ttl = "1h" # add signing_ttl
}
oidc "MyOIDC" {
# ...
}
We add an endpoint to the api block returning the claims from the JWT presented in the UserToken
cookie:
# ...
api {
base_path = "/api"
access_control = ["UserToken"]
endpoint "/userinfo" {
response {
json_body = request.context.UserToken
}
}
}
# ...
The frontend part of our demo application has only one HTML page (index.html) which is served from the htdocs
directory:
server {
files {
document_root = "htdocs"
}
endpoint "/oidc/login" {
# ...
After loading the page, a request is sent to the API's /userinfo
endpoint to get and show some information about a user. If the JWT access control rejects the request, a request is sent to the /oidc/login
endpoint creating the authorization URL, which the browser then calls to start the authentication process.
As our demo application is now complete, we can start it:
$ docker-compose up
We point our browser to http://localhost:8080/
. For a short time, the browser shows the OIDC Demo page with a blank textarea, indicating that no information about the user is available.
The browser is then sent to the OpenID provider for authentication. In a real case, the user would now have to authenticate e.g. via username and password and potentially some additional means, unless she already has a valid session at the OpenID provider. In our example, as the OpenID provider is only a mock, no authentication is needed.
The OpenID provider sends the browser with an authorization code in the query to Couper's redirect endpoint where Couper redeems the code for an access and and ID token. From this information Couper creates its own token, which is stored in the browser in a cookie.
The browser then loads the OIDC Demo page again, now showing some user information in JSON format in the textarea.
Couper also implements the client authentication methods "client_secret_jwt"
and "private_key_jwt"
that use a self-signed JWT for authentication.
With client_secret_jwt
, the JWT is signed with the client_id
using an HS algorithm, so no additional key is necessary.
oidc {
# ...
client_id = "..."
client_secret = "..."
token_endpoint_auth_method = "client_secret_jwt"
jwt_signing_profile {
signature_algorithm = "HS256"
ttl = "10s"
}
}
With private_key_jwt
, the JWT is signed with a private key using an RS or EC algorithm (only the corresponding public key stays at the OpenID provider):
oidc {
# ...
client_id = "..."
token_endpoint_auth_method = "private_key_jwt"
jwt_signing_profile {
key_file = "private_key.pem"
signature_algorithm = "RS256"
ttl = "10s"
}
}
Make sure that the OpenID provider supports the selected client authentication method and, at the OpenID provider, the relying party is configured accordingly.