This project is about creating an extension for Keycloak to improve two-factor authentication (2FA) by allowing users to use an authenticator app. The goal is to make the process more user-friendly. Instead of requiring users to input Time-based One-Time Password (TOTP) codes, they can simply accept or reject login attempts through the app.
During the authentication process, a cryptographic keypair generated by the client is utilized. This keypair enhances security by ensuring the authenticity of the users during the authentication process.
The extension provides the necessary API endpoints to implement such an authenticator app.
Summary of the intended flow:
-
Activation Token Exchange: Keycloak generates an Activation Token that is passed to the client (copy paste or scanning a QR-code)
-
Key Pair Generation: The user's device generates a cryptographic key pair that is put into the device's secure storage. The public key is then registered with Keycloak, associating it with the respective user account. The client also generates and sends a unique authenticator id (uuid).
-
Challenge Transmission: When a user initiates a login attempt, Keycloak sends a challenge to the user's device. This challenge can be transmitted either through a push notification triggered by the login attempt or by polling a specific endpoint on Keycloak.
-
Challenge Signing: The user's device signs the challenge using its private key. This signed challenge is then sent back to Keycloak.
-
Verification Process: Keycloak verifies the signature with the user's public key to ascertain the authenticity of the response. Based on this verification, Keycloak can make an informed decision to either accept or reject the login attempt.
This is an example of the activation token URL that is displayed on the Keycloak My Account Console as QR-Code and copy paste option.
http://192.168.2.127:8080/realms/dev/login-actions/action-token?key=eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICIxYWEzY2FhMS00ZmEwLTQzNTUtYWE1ZC1lZTVhNzc4OTA0NGYifQ.eyJleHAiOjE3MDE0Mjg0NjQsImlhdCI6MTcwMTQyODE2NCwianRpIjoiZTlhZWEyYzQtOWM4Ny00MjBkLTg2NjctNjg0YzA5MjM0ZTA3IiwiaXNzIjoiaHR0cDovLzE5Mi4xNjguMi4xMjc6ODA4MC9yZWFsbXMvZGV2IiwiYXVkIjoiaHR0cDovLzE5Mi4xNjguMi4xMjc6ODA4MC9yZWFsbXMvZGV2Iiwic3ViIjoiYjViZjYzOTYtMjFjZC00NjZjLTk2MzMtMGRlM2ExNTJiYTYzIiwidHlwIjoiYXBwLXNldHVwLWFjdGlvbi10b2tlbiIsIm5vbmNlIjoiZTlhZWEyYzQtOWM4Ny00MjBkLTg2NjctNjg0YzA5MjM0ZTA3Iiwib2FzaWQiOiIwYTE4YTM0MS01ZWE3LTRlZjgtYmRjNS1kZTdmNDY5MjcyYjEuUldfTERIazV6QjguMjk1OTViZmEtNzU4ZS00MjFiLWE4ZDMtMGFmYjQ4ZDE3MjhkIn0.XLh09uLq9Ybx_fCIcMhWcNELy9wnPaGMZ8pRusJ_b_g&client_id=account-console&tab_id=RW_LDHk5zB8
URL Parameters
Key | Example Value | Description |
---|---|---|
tab_id | RW_LDHk5zB8 | Tab Id of the User's Browser Session |
client_id | account-console | Client ID the User is logging in |
key | see below | Keycloak Action Token as JWT |
Decoded Keycloak Action Token
Header
{
"alg": "HS256",
"typ": "JWT",
"kid": "1aa3caa1-4fa0-4355-aa5d-ee5a7789044f"
}
Payload
{
"exp": 1701428464,
"iat": 1701428164,
"jti": "e9aea2c4-9c87-420d-8667-684c09234e07",
"iss": "http://192.168.2.127:8080/realms/dev",
"aud": "http://192.168.2.127:8080/realms/dev",
"sub": "b5bf6396-21cd-466c-9633-0de3a152ba63",
"typ": "app-setup-action-token",
"nonce": "e9aea2c4-9c87-420d-8667-684c09234e07",
"oasid": "0a18a341-5ea7-4ef8-bdc5-de7f469272b1.RW_LDHk5zB8.29595bfa-758e-421b-a8d3-0afb48d1728d"
}
Signature
XLh09uLq9Ybx_fCIcMhWcNELy9wnPaGMZ8pRusJ_b_g
About HTTP Status Codes
4xx
indicate a problem caused by the client.401 Unauthorized
Is returned when the client failed to prove it's identity. E.g. missing authentication information, invalid credentials or a presented JWT that failed verification.403 Forbidden
is returned when the client proved it's identity but is not allowed to access the resource because of missing privileges.5xx
indicate a server sided problem. Error codes from this range MAY NOT be returned if the problem was caused by the client. Any endpoint can return with this status code.
Error Object
Any error response can optionally return an error object as JSON in the body with the following shape. Since it is not possible to return a customized error on the action token endpoint, only the HTTP status code is returned without a body.
{
"error": "some_error_type",
"message": "details about the occurred error"
}
Signature Tokens
The API endpoints require an authentication mechanism that leverages client-side generated keypairs. While drafts for HTTP Message Signatures exist, they are no well-established standards. draft-ietf-httpbis-message-signatures.
Instead of implementing concepts from this unfinished draft, the API uses client-side generated JSON Web Tokens (JWTs) as a form of request signature. The JWTs are signed using a private key stored on the client and transmitted in the x-signature
header.
The client should use the authenticator id as the kid
claim and use an asymmetric signature algorithm. The acceptable algorithms are:
PS512
with anRSASSA-PSS
asymmetric keyES512
with anEC
asymmetric key
The JWT payload should contain:
- The user id as
sub
claim, which the client can extract from thesub
claim of the action token issued by Keycloak via the activation token URL. - An expiration time of approximately 30 seconds to mitigate replay attacks.
- A UUID for the JWT in the
jti
claim, which can be used to implement one-time tokens. The token id should be stored on the Keycloak side at least until the token expires. - A
typ
claim similar to the Keycloak action tokens, containing a value for the corresponding endpoint or action, e.g.,get-challenges
. - Any additional request parameters that need to be signed.
GET /realms/{realmId}/login-actions/action-token
- The Keycloak action token (JWT) that was exchanged beforehand via the activation token URL needs to be passed in the
key
query parameter. - The signature token (JWT) generated by the client in the
x-signature
header with claimtyp
=app-setup-signature-token
.
Consistency Check
The signature token is not used for authentication here but rather for a consistency check to confirm that this token can be verified with the public key sent in this request
Name | In | Description |
---|---|---|
realmId |
path | The Keycloak realm ID |
client_id |
query | The Keycloak client id. This should always be account-console for the setup step. Client receives this value from the activation token URL. |
tab_id |
query | The Keycloak tab ID in the browser session where the user is setting up the authenticator. Client receives this value from the activation token URL. |
key |
query | The from Keycloak generated action token in form of a JWT. Client receives this value from the activation token URL. |
authenticator_id |
query | A unique ID to identify the authenticator. |
device_os |
query | The platform on which the authenticator app is running on. Supported values are: android and ios |
public_key |
query | The X.509 public key (e.g. as PKCS#8 base64 encoded) used to verify signatures |
key_algorithm |
query | Key algorithm of the public key |
device_push_id (optional) |
query | The platform specific ID to receive push notifications. For android this is the Firebase ID |
2xx
Authenticator was successfully registered400 Bad Request
Missing or invalid request parameters. This includes:- parsing of the JWT failed (invalid format)
- request parameters are invalid (e.g. the signature or key algorithm is not supported)
401 Unauthorized
Verification of the JWT from thekey
query parameter failed in any form (expired, missing claims, invalid signature)409 Conflict
The authenticator ID is already registered422 Unprocessable Entity
Verification of the signature token failed with the givenpublic_key
andkey_algorithm
sent by the client.
/realms/{realmId}/challenges
- The signature token (JWT) generated by the client in the
x-signature
header with claimtyp
=app-challenges-signature-token
.
Note: The authenticator id is retrieved from the kid
header of the JWT.
Name | In | Description |
---|---|---|
realmId |
path | The Keycloak realm ID |
2xx
Login challenge dtos as array400 Bad Request
thex-signature
header has a wrong format401 Unauthorized
Thex-signature
header is missing or verification failed412 Precondition Failed
The referencedkid
(authenticator ID) in the signature token does not exist
{
"userName": "johndoe",
"userFirstName": "John",
"userLastName": "Doe",
"targetUrl": "http://192.168.2.127:8080/realms/dev/login-actions/action-token?key=eyJh...",
"codeChallenge": "FlJj9I4WoezeR3MN...",
"updatedTimestamp": 1701426908708,
"ipAddress": "192.168.2.127",
"device": "Other",
"browser": "Firefox/120.0",
"os": "Ubuntu",
"osVersion": "Unknown"
}
Example of the value in targetUrl
http://192.168.2.127:8080/realms/dev/login-actions/action-token?key=eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICIxYWEzY2FhMS00ZmEwLTQzNTUtYWE1ZC1lZTVhNzc4OTA0NGYifQ.eyJleHAiOjE3MDE0MjcyMDgsImlhdCI6MTcwMTQyNjkwOCwianRpIjoiNDNmOWFmMTItZTdjMS00NWJlLTliMDUtYTc3M2ZlNDBiNjk4IiwiaXNzIjoiaHR0cDovLzE5Mi4xNjguMi4xMjc6ODA4MC9yZWFsbXMvZGV2IiwiYXVkIjoiaHR0cDovLzE5Mi4xNjguMi4xMjc6ODA4MC9yZWFsbXMvZGV2Iiwic3ViIjoiYjViZjYzOTYtMjFjZC00NjZjLTk2MzMtMGRlM2ExNTJiYTYzIiwidHlwIjoiYXBwLWF1dGgtYWN0aW9uLXRva2VuIiwibm9uY2UiOiI0M2Y5YWYxMi1lN2MxLTQ1YmUtOWIwNS1hNzczZmU0MGI2OTgiLCJvYXNpZCI6IjRjZTgxYTJjLTlmYjEtNDI2Ni04NjcwLWVmZjIwNzgxNjZmMS5OSzRoRTJvLTZhMC4yOTU5NWJmYS03NThlLTQyMWItYThkMy0wYWZiNDhkMTcyOGQifQ.Nc5dQmBhkuShkLJAMgoHEDdsRWz04594AtIJgCwTICM&client_id=account-console&tab_id=NK4hE2o-6a0
Parameters
Key | Example Value | Description |
---|---|---|
tab_id | NK4hE2o-6a0 | Tab Id of the User's Browser Session |
client_id | account-console | Client ID the User is logging in |
key | see below | Keycloak Action Token as JWT |
Decoded Keycloak Action Token
Header
{
"alg": "HS256",
"typ": "JWT",
"kid": "1aa3caa1-4fa0-4355-aa5d-ee5a7789044f"
}
Payload
{
"exp": 1701427208,
"iat": 1701426908,
"jti": "43f9af12-e7c1-45be-9b05-a773fe40b698",
"iss": "http://192.168.2.127:8080/realms/dev",
"aud": "http://192.168.2.127:8080/realms/dev",
"sub": "b5bf6396-21cd-466c-9633-0de3a152ba63",
"typ": "app-auth-action-token",
"nonce": "43f9af12-e7c1-45be-9b05-a773fe40b698",
"oasid": "4ce81a2c-9fb1-4266-8670-eff2078166f1.NK4hE2o-6a0.29595bfa-758e-421b-a8d3-0afb48d1728d"
}
Signature
Nc5dQmBhkuShkLJAMgoHEDdsRWz04594AtIJgCwTICM
GET /realms/{realmId}/login-actions/action-token
- The JWT generated by Keycloak in the
key
query parameter. It was given to the client via the challenges endpoint or push notification. - The signature token (JWT) generated by the client in the
x-signature
header with claims:typ
:app-auth-signature-token
codeChallenge
: The challenge value to sign
Name | In | Description |
---|---|---|
realmId |
path | The Keycloak realm ID |
client_id |
query | The Keycloak client ID. This should always be account-console for the setup step. Client receives this value from the targetUrl from the ChallengeDTO. |
tab_id |
query | The Keycloak tab ID in the browser session where the user is setting up the authenticator. Client receives this value from the targetUrl from the ChallengeDTO |
key |
query | The from Keycloak generated action token in form of a JWT. Client receives this value from the targetUrl from the ChallengeDTO. |
granted |
query | boolean that indicates of the login attempt was granted or not |
-
2xx
challenge reply was successfully processed -
400 Bad Request
Missing or invalid request parameters. This includes:- parsing of the JWT failed (invalid format)
- the
x-signature
header has a wrong format
-
401 Unauthorized
- The
x-signature
header is missing or signature verification failed - The required JWT in query parameter
key
is expired or verification of the JWT failed in any other form (missing claims, invalid signature)
- The
-
412 Precondition Failed
The referenced key (authenticator ID) in the request signature does not exist
-
The API is based on Keycloaks Action Token Handler to "implement any functionality that initiates or modifies authentication session using action token handler SPI" (Ref. https://www.keycloak.org/docs/latest/server_development/index.html#_action_token_handler_spi)
-
HTTP respones from action token endpoints cannot be modified. They always return HTML
-
The status code can be modifierd but the response body will be empty
-
Public Key is assumed to be encoded according to the X.509 standard: https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/security/spec/X509EncodedKeySpec.html
-
Valid Key Algorithms: https://docs.oracle.com/en/java/javase/17/docs/specs/security/standard-names.html#keyfactory-algorithms
-
Valid Signature Algorithms: https://docs.oracle.com/en/java/javase/17/docs/specs/security/standard-names.html#signature-algorithms
-
During signature validation the app authenticator instantiates a KeyFactory object with the provided key_algorithm. The KeyFactory object will then use the public key specification (public_key) to generate a public key object. Finally, a signature object is instantiated by signature_algorithm and initialized with the public key object to verify the message signature.
Refs:
- https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/security/KeyFactory.html#getInstance(java.lang.String)
- https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/security/KeyFactory.html#generatePublic(java.security.spec.KeySpec)
- https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/security/Signature.html#getInstance(java.lang.String)