From c6d41f374a8ba92a5a10071f1c6446bb371eaf7a Mon Sep 17 00:00:00 2001 From: Ed1ks <46250296+Ed1ks@users.noreply.github.com> Date: Mon, 6 Nov 2023 05:20:28 +0100 Subject: [PATCH] Adds Keycloak Provider (#1165) Co-authored-by: lucythecat --- .auri/$4u4hf1ka.md | 6 + .../content/oauth/providers/keycloak.md | 153 +++++++++++ packages/oauth/src/providers/index.ts | 9 + packages/oauth/src/providers/keycloak.ts | 254 ++++++++++++++++++ 4 files changed, 422 insertions(+) create mode 100644 .auri/$4u4hf1ka.md create mode 100644 documentation/content/oauth/providers/keycloak.md create mode 100644 packages/oauth/src/providers/keycloak.ts diff --git a/.auri/$4u4hf1ka.md b/.auri/$4u4hf1ka.md new file mode 100644 index 000000000..1c119b571 --- /dev/null +++ b/.auri/$4u4hf1ka.md @@ -0,0 +1,6 @@ +--- +package: "@lucia-auth/oauth" # package name +type: "minor" # "major", "minor", "patch" +--- + +Adds Keycloak Provider \ No newline at end of file diff --git a/documentation/content/oauth/providers/keycloak.md b/documentation/content/oauth/providers/keycloak.md new file mode 100644 index 000000000..2310a7e89 --- /dev/null +++ b/documentation/content/oauth/providers/keycloak.md @@ -0,0 +1,153 @@ +--- +title: "Keycloak OAuth provider" +description: "Learn how to use the Keycloak OAuth provider" +--- + +OAuth integration for Keycloak. Refer to [Keycloak Documentation](https://www.keycloak.org/docs/latest/authorization_services/index.html) for getting the required credentials. Provider id is `keycloak`. + +```ts +import { keycloak } from "@lucia-auth/oauth/providers"; +import { auth } from "./lucia.js"; + +const keycloakAuth = keycloak(auth, config); +``` + +## `keycloak()` + +```ts +const keycloak: ( + auth: Auth, + config: { + domain: string; + realm: string; + clientId: string; + clientSecret: string; + scope?: string[]; + redirectUri?: string; + } +) => KeycloakProvider; +``` + +##### Parameters + +| name | type | description | optional | +| --------------------- | ------------------------------------------ | --------------------------------------------------- | :------: | +| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | +| `config.domain` | `string` | Keycloak OAuth app client id (e.g. 'my.domain.com') | | +| `config.realm` | `string` | Keycloak Realm of client | | +| `config.clientId` | `string` | Keycloak OAuth app client id | | +| `config.clientSecret` | `string` | Keycloak OAuth app client secret | | +| `config.scope` | `string[]` | an array of scopes | ✓ | +| `config.redirectUri` | `string` | an authorized redirect URI | ✓ | + +##### Returns + +| type | description | +| --------------------------------------- | ----------------- | +| [`KeycloakProvider`](#keycloakprovider) | Keycloak provider | + +## Interfaces + +### `KeycloakAuth` + +See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). + +```ts +// implements OAuth2ProviderAuth> + +interface KeycloakAuth<_Auth extends Auth> { + getAuthorizationUrl: () => Promise; + validateCallback: (code: string) => Promise>; +} +``` + +| type | +| --------------------------------------- | +| [`KeycloakUserAuth`](#keycloakuserauth) | + +##### Generics + +| name | extends | default | +| ------- | ------------------------------------------ | ------- | +| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | + +### `KeycloakTokens` + +```ts +type KeycloakTokens = { + accessToken: string; + accessTokenExpiresIn: number; + authTime: number; + issuedAtTime: number; + expirationTime: number; + refreshToken: string | null; + refreshTokenExpiresIn: number | null; +}; +``` + +### `KeycloakUser` + +```ts +type KeycloakUser = { + exp: number; + iat: number; + auth_time: number; + jti: string; + iss: string; + aud: string; + sub: string; + typ: string; + azp: string; + session_state: string; + at_hash: string; + acr: string; + sid: string; + email_verified: boolean; + name: string; + preferred_username: string; + given_name: string; + locale: string; + family_name: string; + email: string; + picture: string; + user: any; +}; +``` + +### `KeycloakRole` + +```ts +type KeycloakUser = PublicKeycloakUser | PrivateKeycloakUser; + +type KeycloakRole = { + role_type: "realm" | "resource"; + + client: null | string; // null if realm_access + + role: string; +}; +``` + +### `KeycloakUserAuth` + +Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). + +```ts +interface KeycloakUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { + keycloakUser: KeycloakUser; + keycloakTokens: KeycloakTokens; + keycloakRoles: KeycloakRoles; +} +``` + +| properties | type | description | +| ---------------- | ----------------------------------- | ---------------------------------------- | +| `keycloakUser` | [`KeycloakUser`](#keycloakuser) | Keycloak user | +| `keycloakTokens` | [`KeycloakTokens`](#keycloaktokens) | Access tokens etc | +| `keycloakRoles` | [`KeycloakRoles`](#keycloakroles) | Keycloak roles retrieved from OIDC Token | + +##### Generics + +| name | extends | +| ------- | ------------------------------------------ | +| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/packages/oauth/src/providers/index.ts b/packages/oauth/src/providers/index.ts index 101c973f3..c214d97ff 100644 --- a/packages/oauth/src/providers/index.ts +++ b/packages/oauth/src/providers/index.ts @@ -97,6 +97,15 @@ export type { GoogleUserAuth } from "./google.js"; +export { keycloak } from "./keycloak.js"; +export type { + KeycloakAuth, + KeycloakTokens, + KeycloakUser, + KeycloakRole, + KeycloakUserAuth +} from "./keycloak.js"; + export { lichess } from "./lichess.js"; export type { LichessAuth, diff --git a/packages/oauth/src/providers/keycloak.ts b/packages/oauth/src/providers/keycloak.ts new file mode 100644 index 000000000..d25011020 --- /dev/null +++ b/packages/oauth/src/providers/keycloak.ts @@ -0,0 +1,254 @@ +import { + OAuth2ProviderAuthWithPKCE, + createOAuth2AuthorizationUrlWithPKCE, + validateOAuth2AuthorizationCode +} from "../core/oauth2.js"; +import { ProviderUserAuth } from "../core/provider.js"; +import { decodeIdToken } from "../index.js"; +import { handleRequest, authorizationHeader } from "../utils/request.js"; + +import type { Auth } from "lucia"; + +type Config = { + domain: string; + realm: string; + clientId: string; + clientSecret: string; + scope?: string[]; + redirectUri?: string; +}; + +const PROVIDER_ID = "keycloak"; + +export const keycloak = <_Auth extends Auth = Auth>( + auth: _Auth, + config: Config +): KeycloakAuth<_Auth> => { + return new KeycloakAuth(auth, config); +}; + +export class KeycloakAuth< + _Auth extends Auth = Auth +> extends OAuth2ProviderAuthWithPKCE> { + private config: Config; + + constructor(auth: _Auth, config: Config) { + super(auth); + + this.config = config; + } + + public getAuthorizationUrl = async (): Promise< + readonly [url: URL, codeVerifier: string, state: string] + > => { + const scopeConfig = this.config.scope ?? []; + return await createOAuth2AuthorizationUrlWithPKCE( + `https://${this.config.domain}/realms/${this.config.realm}/protocol/openid-connect/auth`, + { + clientId: this.config.clientId, + scope: ["profile", "openid", ...scopeConfig], + redirectUri: this.config.redirectUri, + codeChallengeMethod: "S256" + } + ); + }; + + public validateCallback = async ( + code: string, + code_verifier: string + ): Promise> => { + const keycloakTokens = await this.validateAuthorizationCode( + code, + code_verifier + ); + const keycloakUser = await getKeycloakUser( + this.config.domain, + this.config.realm, + keycloakTokens.accessToken + ); + const keycloakRoles = getKeycloakRoles(keycloakTokens.accessToken); + return new KeycloakUserAuth( + this.auth, + keycloakUser, + keycloakTokens, + keycloakRoles + ); + }; + + private validateAuthorizationCode = async ( + code: string, + codeVerifier: string + ): Promise => { + const rawTokens = + await validateOAuth2AuthorizationCode( + code, + `https://${this.config.domain}/realms/${this.config.realm}/protocol/openid-connect/token`, + { + clientId: this.config.clientId, + redirectUri: this.config.redirectUri, + codeVerifier, + clientPassword: { + authenticateWith: "http_basic_auth", + clientSecret: this.config.clientSecret + } + } + ); + + return this.claimTokens(rawTokens); + }; + + private claimTokens = (tokens: AccessTokenResponseBody): KeycloakTokens => { + if ("refresh_token" in tokens) { + return { + accessToken: tokens.access_token, + accessTokenExpiresIn: tokens.expires_in, + authTime: tokens.auth_time, + issuedAtTime: tokens.issued_at_time, + expiresAt: tokens.expires_at, + refreshToken: tokens.refresh_token, + refreshTokenExpiresIn: tokens.refresh_expires_in + }; + } + return { + accessToken: tokens.access_token, + accessTokenExpiresIn: tokens.expires_in, + authTime: tokens.auth_time, + issuedAtTime: tokens.issued_at_time, + expiresAt: tokens.expires_at, + refreshToken: null, + refreshTokenExpiresIn: null + }; + }; +} + +const getKeycloakUser = async ( + domain: string, + realm: string, + accessToken: string +): Promise => { + const keycloakUserRequest = new Request( + `https://${domain}/realms/${realm}/protocol/openid-connect/userinfo`, + { + headers: { + Authorization: authorizationHeader("bearer", accessToken) + } + } + ); + return await handleRequest(keycloakUserRequest); +}; + +const getKeycloakRoles = (accessToken: string): KeycloakRole[] => { + const tokenDecoded: Claims = decodeIdToken(accessToken); + const keycloakRoles: KeycloakRole[] = []; + + if ("realm_access" in tokenDecoded) { + for (const role of tokenDecoded.realm_access.roles) { + keycloakRoles.push({ + role_type: "realm", + client: null, + role: role + }); + } + } + if ("resource_access" in tokenDecoded) { + for (const [key, client] of Object.entries(tokenDecoded.resource_access)) { + for (const role of client.roles) { + keycloakRoles.push({ + role_type: "resource", + client: key, + role: role + }); + } + } + } + + return keycloakRoles; +}; + +export class KeycloakUserAuth< + _Auth extends Auth +> extends ProviderUserAuth<_Auth> { + public keycloakTokens: KeycloakTokens; + public keycloakUser: KeycloakUser; + public keycloakRoles: KeycloakRole[]; + + constructor( + auth: _Auth, + keycloakUser: KeycloakUser, + keycloakTokens: KeycloakTokens, + keycloakRoles: KeycloakRole[] + ) { + super(auth, PROVIDER_ID, keycloakUser.sub); + + this.keycloakTokens = keycloakTokens; + this.keycloakUser = keycloakUser; + this.keycloakRoles = keycloakRoles; + } +} + +type AccessTokenResponseBody = + | { + access_token: string; + expires_in: number; + auth_time: number; + issued_at_time: number; + expires_at: number; + } + | { + access_token: string; + expires_in: number; + auth_time: number; + issued_at_time: number; + expires_at: number; + refresh_token: string; + refresh_expires_in: number; + }; + +export type KeycloakTokens = { + accessToken: string; + accessTokenExpiresIn: number; + authTime: number; + issuedAtTime: number; + expiresAt: number; + refreshToken: string | null; + refreshTokenExpiresIn: number | null; +}; + +export type Claims = { + exp: number; + iat: number; + auth_time: number; + realm_access: { roles: string[] }; + resource_access: { [key: string]: { roles: string[] } }; +}; + +export type KeycloakUser = { + exp: number; + iat: number; + auth_time: number; + jti: string; + iss: string; + aud: string; + sub: string; // user_id + typ: string; + azp: string; + session_state: string; + at_hash: string; + acr: string; + sid: string; + email_verified: boolean; + name: string; + preferred_username: string; + given_name: string; + locale: string; + family_name: string; + email: string; + picture: string; + user: any; +}; + +export type KeycloakRole = { + role_type: "realm" | "resource"; + client: null | string; // null if realm_access + role: string; +};