diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..4ca1fe66 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +# Slim context for docker/keycloak/Dockerfile (build context: Module-Management root) +Client/node_modules +Client/dist +Server/build +Server/.gradle +**/.git +.git diff --git a/.example.env b/.example.env index ba1772e2..37436af4 100644 --- a/.example.env +++ b/.example.env @@ -46,3 +46,5 @@ EMBEDDING_MODEL_NAME= sentence-transformers/all-mpnet-base-v2 CHAT_MODEL_SOURCE = OpenAiChatModel EMBEDDING_MODEL_SOURCE = OpenAiEmbeddingModel +KC_PASSKEY_CLIENT_ID=module-management +KC_ALLOWED_BROWSER_ORIGIN=https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?|https://module\.aet\.cit\.tum\.de diff --git a/Client/src/app/components/header/header.component.html b/Client/src/app/components/header/header.component.html index b2a89521..b552d2f3 100644 --- a/Client/src/app/components/header/header.component.html +++ b/Client/src/app/components/header/header.component.html @@ -13,9 +13,7 @@ CIT Module Management -
- - - @if (user() !== undefined) { - - - - } @else { - - } +
diff --git a/Client/src/app/components/header/header.component.ts b/Client/src/app/components/header/header.component.ts index b552e0af..ab342555 100644 --- a/Client/src/app/components/header/header.component.ts +++ b/Client/src/app/components/header/header.component.ts @@ -4,16 +4,14 @@ import { SecurityStore } from '../../core/security/security-store.service'; import { ThemeService } from '../../core/theme/theme.service'; import { SidebarService } from '../side-bar/sidebar.service'; import { ButtonModule } from 'primeng/button'; -import { AvatarModule } from 'primeng/avatar'; -import { MenuModule } from 'primeng/menu'; import { TooltipModule } from 'primeng/tooltip'; -import { MenuItem } from 'primeng/api'; +import { SignInComponent } from '../sign-in/sign-in.component'; @Component({ selector: 'app-header', templateUrl: './header.component.html', standalone: true, - imports: [RouterLink, ButtonModule, AvatarModule, MenuModule, TooltipModule] + imports: [RouterLink, ButtonModule, TooltipModule, SignInComponent] }) export class HeaderComponent { securityStore = inject(SecurityStore); @@ -23,31 +21,7 @@ export class HeaderComponent { user = this.securityStore.user; isDarkMode = this.themeService.isDarkMode; - menuItems: MenuItem[] = [ - { - label: 'Settings', - icon: 'pi pi-cog', - routerLink: '/account' - }, - { - separator: true - }, - { - label: 'Sign Out', - icon: 'pi pi-sign-out', - command: () => this.signOut() - } - ]; - toggleTheme() { this.themeService.toggleTheme(); } - - signIn() { - this.securityStore.signIn(); - } - - signOut() { - this.securityStore.signOut(); - } } diff --git a/Client/src/app/components/sign-in/sign-in.component.html b/Client/src/app/components/sign-in/sign-in.component.html new file mode 100644 index 00000000..057109c0 --- /dev/null +++ b/Client/src/app/components/sign-in/sign-in.component.html @@ -0,0 +1,33 @@ +@if (user() !== undefined) { + + + +} @else { + + + + +} diff --git a/Client/src/app/components/sign-in/sign-in.component.ts b/Client/src/app/components/sign-in/sign-in.component.ts new file mode 100644 index 00000000..d92b434a --- /dev/null +++ b/Client/src/app/components/sign-in/sign-in.component.ts @@ -0,0 +1,35 @@ +import { Component, inject } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { ButtonModule } from 'primeng/button'; +import { ButtonGroupModule } from 'primeng/buttongroup'; +import { MenuModule } from 'primeng/menu'; +import { TooltipModule } from 'primeng/tooltip'; +import { MenuItem } from 'primeng/api'; +import { SecurityStore } from '../../core/security/security-store.service'; + +@Component({ + selector: 'app-sign-in', + standalone: true, + imports: [RouterModule, ButtonModule, ButtonGroupModule, MenuModule, TooltipModule], + templateUrl: './sign-in.component.html' +}) +export class SignInComponent { + readonly securityStore = inject(SecurityStore); + readonly user = this.securityStore.user; + + menuItems: MenuItem[] = [ + { + label: 'Settings', + icon: 'pi pi-cog', + routerLink: '/account' + }, + { + separator: true + }, + { + label: 'Sign Out', + icon: 'pi pi-sign-out', + command: () => this.securityStore.signOut() + } + ]; +} diff --git a/Client/src/app/core/security/keycloak.service.ts b/Client/src/app/core/security/keycloak.service.ts index f17f6bd7..02b655a5 100644 --- a/Client/src/app/core/security/keycloak.service.ts +++ b/Client/src/app/core/security/keycloak.service.ts @@ -54,18 +54,14 @@ export class KeycloakService { } } - login(returnUrl?: string) { - return this.keycloak.login({ redirectUri: window.location.origin + (returnUrl || ''), action: 'webauthn-register-passwordless:skip_if_exists' }); + loginWithTumRedirect(returnUrl?: string) { + return this.keycloak.login({ redirectUri: window.location.origin + (returnUrl ?? '') }); } logout() { return this.keycloak.logout({ redirectUri: environment.redirect }); } - registerPasskey(returnUrl?: string) { - return this.keycloak.login({ redirectUri: window.location.origin + (returnUrl || ''), action: 'webauthn-register-passwordless' }); - } - getCredentials() { const url = `${environment.keycloak.url}/realms/${environment.keycloak.realm}/account/credentials`; return this.http.get(url); diff --git a/Client/src/app/core/security/passkey-extension.service.ts b/Client/src/app/core/security/passkey-extension.service.ts new file mode 100644 index 00000000..a7e5a480 --- /dev/null +++ b/Client/src/app/core/security/passkey-extension.service.ts @@ -0,0 +1,183 @@ +import { inject, Injectable } from '@angular/core'; +import { environment } from '../../../../environments/environment'; +import { KeycloakService } from './keycloak.service'; + +/** + * In-app WebAuthn via Keycloak realm extension (same flow as ba-test-keycloak {@code public/app.js}): + * register: {@code /passkey/challenge} + {@code /passkey/save}; sign-in: {@code /passkey/get-credential-id} + {@code /passkey/authenticate}. + */ +@Injectable({ providedIn: 'root' }) +export class PasskeyExtensionService { + private readonly keycloakService = inject(KeycloakService); + + private passkeyBaseUrl(): string { + const base = environment.keycloak.url.replace(/\/$/, ''); + const realm = encodeURIComponent(environment.keycloak.realm); + return `${base}/realms/${realm}/passkey`; + } + + private getUrl(path: string): string { + const p = path.replace(/^\/+/, ''); + return `${this.passkeyBaseUrl()}/${p}`; + } + + private base64UrlToUint8Array(value: string): Uint8Array { + const base64 = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)); + } + + private bufferToBase64Url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + } + + private async readJsonBody(response: Response): Promise { + const contentType = response.headers.get('content-type') ?? ''; + if (!contentType.toLowerCase().includes('application/json')) { + return undefined; + } + try { + return (await response.json()) as T; + } catch { + return undefined; + } + } + + /** + * Register a new passkey for the current user (must already be logged in). + */ + async registerPasskeyInBrowser(): Promise { + const kc = this.keycloakService.keycloak; + if (!kc.authenticated || !kc.token) { + throw new Error('You must be signed in to register a passkey.'); + } + + await kc.updateToken(60); + const token = kc.token; + if (!token) { + throw new Error('No access token available.'); + } + + const parsed = kc.tokenParsed as Record | undefined; + const accountId = String(parsed?.['sub'] ?? parsed?.['preferred_username'] ?? ''); + const accountName = String(parsed?.['preferred_username'] ?? parsed?.['email'] ?? ''); + const displayName = String(parsed?.['name'] ?? ([parsed?.['given_name'], parsed?.['family_name']].filter(Boolean).join(' ') || accountName || 'User')); + + if (!accountId || !accountName) { + throw new Error('Missing user identity in token for passkey registration.'); + } + + const challengeRes = await fetch(this.getUrl('challenge'), { credentials: 'include' }); + if (!challengeRes.ok) { + throw new Error(`Failed to get WebAuthn challenge (${challengeRes.status})`); + } + const { challenge } = (await challengeRes.json()) as { challenge: string }; + if (!challenge) { + throw new Error('Invalid challenge response from Keycloak'); + } + + const userIdBytes = new TextEncoder().encode(accountId).slice(0, 64); + + const credential = (await navigator.credentials.create({ + publicKey: { + challenge: this.base64UrlToUint8Array(challenge) as BufferSource, + rp: { name: 'Module Management', id: window.location.hostname }, + user: { id: userIdBytes, name: accountName, displayName }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + authenticatorSelection: { userVerification: 'preferred', residentKey: 'required' }, + attestation: 'none' + } + })) as PublicKeyCredential | null; + + if (!credential?.response) { + throw new Error('Passkey creation was cancelled or failed.'); + } + + const response = credential.response as AuthenticatorAttestationResponse; + const savePayload = { + credentialId: this.bufferToBase64Url(credential.rawId), + rawId: this.bufferToBase64Url(credential.rawId), + clientDataJSON: this.bufferToBase64Url(response.clientDataJSON), + attestationObject: this.bufferToBase64Url(response.attestationObject), + challenge + }; + + const saveRes = await fetch(this.getUrl('save'), { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(savePayload) + }); + + const saveText = await saveRes.text(); + if (!saveRes.ok) { + throw new Error(saveText || `Failed to store passkey (${saveRes.status})`); + } + + await kc.updateToken(-1); + } + + /** + * Sign in with passkey only (no Keycloak UI redirect). + * The extension endpoint sets the Keycloak login cookie; the SPA should then reload + * and let keycloak-js initialize via check-sso. + */ + async signInWithPasskey(): Promise { + const optionsResponse = await fetch(this.getUrl('challenge'), { credentials: 'include' }); + const res = await this.readJsonBody<{ challenge?: string; credentialId?: string; error?: string }>(optionsResponse); + if (!optionsResponse.ok) { + throw new Error(res?.error || `Failed to get passkey options (${optionsResponse.status})`); + } + if (!res?.challenge) { + throw new Error('Invalid challenge response from server'); + } + + const publicKey: PublicKeyCredentialRequestOptions = { + challenge: this.base64UrlToUint8Array(res.challenge) as BufferSource, + userVerification: 'preferred' + }; + if (res.credentialId) { + publicKey.allowCredentials = [{ type: 'public-key', id: this.base64UrlToUint8Array(res.credentialId) as BufferSource }]; + } + + const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential | null; + if (!credential?.response) { + throw new Error('Passkey sign-in was cancelled or failed.'); + } + + const ar = credential.response as AuthenticatorAssertionResponse; + const payload = { + credentialId: this.bufferToBase64Url(credential.rawId), + rawId: this.bufferToBase64Url(credential.rawId), + clientDataJSON: this.bufferToBase64Url(ar.clientDataJSON), + authenticatorData: this.bufferToBase64Url(ar.authenticatorData), + signature: this.bufferToBase64Url(ar.signature), + challenge: res.challenge + }; + + const authRes = await fetch(this.getUrl('authenticate'), { + method: 'POST', + credentials: 'include', + redirect: 'manual', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (authRes.type === 'opaqueredirect') { + return; + } + + const authResult = await this.readJsonBody<{ error?: string }>(authRes); + if (!authRes.ok) { + throw new Error(authResult?.error || `Passkey authentication failed (${authRes.status})`); + } + } +} diff --git a/Client/src/app/core/security/security-store.service.ts b/Client/src/app/core/security/security-store.service.ts index 90909ba4..0adb1bc7 100644 --- a/Client/src/app/core/security/security-store.service.ts +++ b/Client/src/app/core/security/security-store.service.ts @@ -1,18 +1,27 @@ import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core'; import { isPlatformServer } from '@angular/common'; import { KeycloakService } from './keycloak.service'; +import { PasskeyExtensionService } from './passkey-extension.service'; import { firstValueFrom } from 'rxjs'; import { UserControllerService, User } from '../modules/openapi'; import { Passkey } from './keycloak-credentials.types'; +import { MessageService } from 'primeng/api'; + +function passkeyDialogDismissedStorageKey(sub: string): string { + return `mm_passkey_dialog_dismissed_${sub}`; +} @Injectable({ providedIn: 'root' }) export class SecurityStore { keycloakService = inject(KeycloakService); + passkeyExtension = inject(PasskeyExtensionService); userControllerService = inject(UserControllerService); + private readonly messageService = inject(MessageService); isLoading = signal(false); user = signal(undefined); passkeys = signal([]); + passkeyDialogVisible = signal(false); constructor() { this.onInit(); @@ -29,36 +38,86 @@ export class SecurityStore { const isLoggedIn = await this.keycloakService.init(); if (isLoggedIn) { - this.loadPasskeys(); + await this.loadPasskeys(); try { const user = await firstValueFrom(this.userControllerService.getCurrentUser()); this.user.set(user); } catch (error) { + this.messageService.add({ severity: 'error', summary: 'Sign-in', detail: 'Something went wrong' }); console.error('error fetching user details', error); this.user.set(undefined); } + this.evaluatePasskeyDialogAfterTumLogin(); } this.isLoading.set(false); } + closePasskeyDialog(dontShowAgain: boolean): void { + if (!this.passkeyDialogVisible()) { + return; + } + if (dontShowAgain) { + const sub = this.keycloakService.keycloak.tokenParsed?.sub; + if (sub) { + localStorage.setItem(passkeyDialogDismissedStorageKey(sub), '1'); + } + } + this.passkeyDialogVisible.set(false); + } + + private evaluatePasskeyDialogAfterTumLogin(): void { + if (this.passkeys().length > 0) { + return; + } + const sub = this.keycloakService.keycloak.tokenParsed?.sub; + if (!sub) { + return; + } + if (localStorage.getItem(passkeyDialogDismissedStorageKey(sub)) === '1') { + return; + } + this.passkeyDialogVisible.set(true); + } + + async signInWithTum(returnUrl?: string) { + await this.keycloakService.loginWithTumRedirect(returnUrl); + } + async signIn(returnUrl?: string) { - await this.keycloakService.login(returnUrl); + await this.signInWithTum(returnUrl); + } + + async signInWithPasskey(): Promise { + this.isLoading.set(true); + try { + await this.passkeyExtension.signInWithPasskey(); + // Passkey authenticate sets the Keycloak login cookie; reload so init(check-sso) + // can bootstrap keycloak-js token state through the standard adapter flow. + window.location.reload(); + } catch (error) { + this.messageService.add({ severity: 'error', summary: 'Sign-in', detail: 'Something went wrong' }); + console.error(error); + } finally { + this.isLoading.set(false); + } } async signOut() { await this.keycloakService.logout(); this.user.set(undefined); this.passkeys.set([]); + this.passkeyDialogVisible.set(false); } - async registerPasskey(returnUrl?: string) { - return await this.keycloakService.registerPasskey(returnUrl); + async registerPasskey(_returnUrl?: string) { + await this.passkeyExtension.registerPasskeyInBrowser(); + await this.loadPasskeys(); } async deletePasskey(credentialId: string) { try { await firstValueFrom(this.keycloakService.deleteCredential(credentialId)); - this.loadPasskeys(); + await this.loadPasskeys(); } catch (error) { console.error('Error deleting passkey:', error); } diff --git a/Client/src/app/layout.component.html b/Client/src/app/layout.component.html index 0e9c8948..956e9709 100644 --- a/Client/src/app/layout.component.html +++ b/Client/src/app/layout.component.html @@ -9,4 +9,37 @@ + + +
+

+ You can add a + passkey + to sign in next time without leaving this app—using your device PIN, fingerprint, or security key. +

+
    +
  • Quicker sign-in for day-to-day work
  • +
  • Phishing-resistant authentication
  • +
  • Your passkey stays on your device; you can remove it anytime in Account → Passkeys
  • +
+
+ + +
+
+ + + + +
diff --git a/Client/src/app/layout.component.ts b/Client/src/app/layout.component.ts index 451ea8f6..3481161f 100644 --- a/Client/src/app/layout.component.ts +++ b/Client/src/app/layout.component.ts @@ -1,19 +1,44 @@ import { Component, inject } from '@angular/core'; -import { RouterModule } from '@angular/router'; +import { Router, RouterModule } from '@angular/router'; +import { FormsModule } from '@angular/forms'; import { HeaderComponent } from './components/header/header.component'; import { SideBarComponent } from './components/side-bar/side-bar.component'; import { BreadcrumbComponent } from './components/breadcrumb/breadcrumb.component'; import { SidebarService } from './components/side-bar/sidebar.service'; import { SecurityStore } from './core/security/security-store.service'; +import { DialogModule } from 'primeng/dialog'; +import { ButtonModule } from 'primeng/button'; +import { CheckboxModule } from 'primeng/checkbox'; @Component({ selector: 'app-layout', standalone: true, - imports: [RouterModule, HeaderComponent, SideBarComponent, BreadcrumbComponent], + imports: [RouterModule, FormsModule, HeaderComponent, SideBarComponent, BreadcrumbComponent, DialogModule, ButtonModule, CheckboxModule], templateUrl: './layout.component.html' }) export class LayoutComponent { sidebarService = inject(SidebarService); securityStore = inject(SecurityStore); + private readonly router = inject(Router); user = this.securityStore.user; + + dontShowPasskeyDialogAgain = false; + + onPasskeyDialogVisibleChange(visible: boolean): void { + if (visible) { + this.dontShowPasskeyDialogAgain = false; + return; + } + this.securityStore.closePasskeyDialog(this.dontShowPasskeyDialogAgain); + } + + goToPasskeyRegistration(): void { + const skip = this.dontShowPasskeyDialogAgain; + this.securityStore.closePasskeyDialog(skip); + void this.router.navigate(['/account/passkeys']); + } + + dismissPasskeyDialog(): void { + this.securityStore.closePasskeyDialog(this.dontShowPasskeyDialogAgain); + } } diff --git a/Client/src/app/pages/account-management/passkeys/account-passkeys.component.ts b/Client/src/app/pages/account-management/passkeys/account-passkeys.component.ts index 9a8e4cfa..80fc66b4 100644 --- a/Client/src/app/pages/account-management/passkeys/account-passkeys.component.ts +++ b/Client/src/app/pages/account-management/passkeys/account-passkeys.component.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { SecurityStore } from '../../../core/security/security-store.service'; import { PanelModule } from 'primeng/panel'; import { ButtonModule } from 'primeng/button'; +import { MessageService } from 'primeng/api'; @Component({ selector: 'account-passkeys', @@ -12,11 +13,21 @@ import { ButtonModule } from 'primeng/button'; }) export class AccountPasskeysComponent { securityStore = inject(SecurityStore); + private readonly messageService = inject(MessageService); passkeys = this.securityStore.passkeys; async addPasskey() { - await this.securityStore.registerPasskey(window.location.pathname); + try { + await this.securityStore.registerPasskey(window.location.pathname); + } catch (e: unknown) { + const detail = e instanceof Error ? e.message : 'Passkey registration failed'; + this.messageService.add({ + severity: 'error', + summary: 'Passkey registration', + detail + }); + } } async deletePasskey(passkeyId: string) { diff --git a/Client/src/app/pages/index/index.component.html b/Client/src/app/pages/index/index.component.html index 24a3f946..bed247af 100644 --- a/Client/src/app/pages/index/index.component.html +++ b/Client/src/app/pages/index/index.component.html @@ -6,8 +6,10 @@ Sign in required -

Log in to access the Module Management application.

- +

Sign in to access the Module Management application.

+
+ +
diff --git a/Client/src/app/pages/index/index.component.ts b/Client/src/app/pages/index/index.component.ts index 968daf74..63ac1578 100644 --- a/Client/src/app/pages/index/index.component.ts +++ b/Client/src/app/pages/index/index.component.ts @@ -4,12 +4,13 @@ import { ButtonModule } from 'primeng/button'; import { DividerModule } from 'primeng/divider'; import { PanelModule } from 'primeng/panel'; import { SecurityStore } from '../../core/security/security-store.service'; +import { SignInComponent } from '../../components/sign-in/sign-in.component'; import { isAdminRole, isProfessorRole, isReviewerRole } from '../../core/shared/user-role.utils'; @Component({ selector: 'index-component', standalone: true, - imports: [RouterModule, ButtonModule, DividerModule, PanelModule], + imports: [RouterModule, ButtonModule, DividerModule, PanelModule, SignInComponent], templateUrl: './index.component.html' }) export class IndexComponent { diff --git a/Client/tsconfig.app.json b/Client/tsconfig.app.json index 3775b37e..264f459b 100644 --- a/Client/tsconfig.app.json +++ b/Client/tsconfig.app.json @@ -6,10 +6,10 @@ "outDir": "./out-tsc/app", "types": [] }, - "files": [ - "src/main.ts" - ], "include": [ - "src/**/*.d.ts" + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" ] } diff --git a/docker/docker-compose.dev.yaml b/docker/docker-compose.dev.yaml index 335d7eb8..21bf8e5e 100644 --- a/docker/docker-compose.dev.yaml +++ b/docker/docker-compose.dev.yaml @@ -15,13 +15,20 @@ services: restart: unless-stopped keycloak: - image: quay.io/keycloak/keycloak:26.5.3 + build: + context: .. + dockerfile: docker/keycloak/Dockerfile + image: module-management-keycloak:dev + pull_policy: build container_name: module-management-keycloak environment: KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin} KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} + KC_PASSKEY_CLIENT_ID: ${KC_PASSKEY_CLIENT_ID:-module-management} + KC_ALLOWED_BROWSER_ORIGIN: ${KC_ALLOWED_BROWSER_ORIGIN:-http://localhost:4200} command: start-dev --import-realm volumes: + - keycloak_data:/opt/keycloak/data - ../module-management-realm.json:/opt/keycloak/data/import/module-management-realm.json - ../keycloak-themes:/opt/keycloak/themes ports: @@ -65,6 +72,7 @@ services: volumes: postgres_data: + keycloak_data: hf_cache: diff --git a/docker/docker-compose.staging.yaml b/docker/docker-compose.staging.yaml index 3f6b0255..e1d3e570 100644 --- a/docker/docker-compose.staging.yaml +++ b/docker/docker-compose.staging.yaml @@ -33,11 +33,13 @@ services: restart: unless-stopped keycloak: - image: quay.io/keycloak/keycloak:26.5.3 + image: quay.io/keycloak/keycloak:26.5 container_name: module-management-keycloak environment: - KC_BOOTSTRAP_ADMIN_USERNAME=${KEYCLOAK_ADMIN_USERNAME} - KC_BOOTSTRAP_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD} + - KC_PASSKEY_CLIENT_ID=${KC_PASSKEY_CLIENT_ID:-module-management} + - KC_ALLOWED_BROWSER_ORIGIN=${KC_ALLOWED_BROWSER_ORIGIN:-http://localhost:4200} - KC_DB=postgres - KC_DB_URL_HOST=postgres - KC_DB_URL_DATABASE=keycloak diff --git a/docker/keycloak/Dockerfile b/docker/keycloak/Dockerfile new file mode 100644 index 00000000..e5f305a8 --- /dev/null +++ b/docker/keycloak/Dockerfile @@ -0,0 +1,15 @@ +# Keycloak image with Module-Management passkey realm extension (WebAuthn /passkey/*) +FROM maven:3.9-eclipse-temurin-21 AS extension-build +WORKDIR /build +COPY keycloak-extension-passkey /build/extension +WORKDIR /build/extension +RUN mvn -q -DskipTests package + +FROM quay.io/keycloak/keycloak:26.5 AS kc-with-provider +COPY --from=extension-build --chown=keycloak:keycloak \ + /build/extension/target/custom-endpoint-1.0-SNAPSHOT.jar \ + /opt/keycloak/providers/module-management-passkey.jar +RUN /opt/keycloak/bin/kc.sh build + +FROM quay.io/keycloak/keycloak:26.5 +COPY --from=kc-with-provider /opt/keycloak/ /opt/keycloak/ diff --git a/keycloak-extension-passkey/pom.xml b/keycloak-extension-passkey/pom.xml new file mode 100644 index 00000000..c7a49cea --- /dev/null +++ b/keycloak-extension-passkey/pom.xml @@ -0,0 +1,79 @@ + + 4.0.0 + + com.example.keycloak + custom-endpoint + 1.0-SNAPSHOT + jar + + custom-endpoint + http://maven.apache.org + + + UTF-8 + 26.6.1 + + + + + org.keycloak + keycloak-server-spi + ${keycloak.version} + provided + + + org.keycloak + keycloak-services + ${keycloak.version} + provided + + + org.keycloak + keycloak-server-spi-private + ${keycloak.version} + provided + + + org.jboss.resteasy + resteasy-core-spi + 6.2.11.Final + provided + + + org.projectlombok + lombok + 1.18.38 + provided + + + com.fasterxml.jackson.core + jackson-core + 2.18.3 + + + com.fasterxml.jackson.core + jackson-databind + 2.18.3 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 17 + + + org.projectlombok + lombok + 1.18.38 + + + + + + + diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyBrowserLoginService.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyBrowserLoginService.java new file mode 100644 index 00000000..8f4ec276 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyBrowserLoginService.java @@ -0,0 +1,212 @@ +package com.example.keycloak; + +import com.webauthn4j.data.client.challenge.Challenge; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import org.keycloak.OAuth2Constants; +import org.keycloak.common.ClientConnection; +import org.keycloak.common.util.Base64Url; +import org.keycloak.common.util.UriUtils; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.utils.OIDCResponseType; +import org.keycloak.protocol.oidc.utils.RedirectUtils; +import org.keycloak.services.Urls; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.managers.AuthenticationSessionManager; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.sessions.CommonClientSessionModel; +import org.keycloak.sessions.RootAuthenticationSessionModel; + +import jakarta.ws.rs.core.Response; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +final class PasskeyBrowserLoginService { + + private static final String HEADER_ORIGIN = "Origin"; + private static final String HEADER_REFERER = "Referer"; + private static final String RESPONSE_MODE_QUERY = "query"; + + private final KeycloakSession session; + private final PasskeyClientSupport clientSupport; + + /** + * Creates a helper that continues the standard Keycloak browser login flow. + * + * @param session Keycloak request session + * @param clientSupport helper for resolving configured client + */ + PasskeyBrowserLoginService(KeycloakSession session, PasskeyClientSupport clientSupport) { + this.session = session; + this.clientSupport = clientSupport; + } + + /** + * Builds an authenticated browser flow response for a passkey-authenticated user. + * + * @param user authenticated user + * @param realm current realm + * @return Keycloak follow-up response from the authentication manager + */ + Response completeLogin(UserModel user, RealmModel realm) { + ClientModel client = clientSupport.requireConfiguredClient(realm); + String redirectUri = resolveValidatedRedirectUri(client); + if (redirectUri == null) { + throw new IllegalArgumentException("No valid redirect URI for client"); + } + + session.getContext().setClient(client); + AuthenticationSessionModel authenticationSession = createBrowserAuthenticationSession(realm, client, user, redirectUri); + session.getContext().setAuthenticationSession(authenticationSession); + + ClientConnection connection = session.getContext().getConnection(); + EventBuilder event = new EventBuilder(realm, session, connection) + .event(EventType.LOGIN) + .client(client) + .user(user); + return AuthenticationManager.nextActionAfterAuthentication( + session, + authenticationSession, + connection, + session.getContext().getHttpRequest(), + session.getContext().getUri(), + event + ); + } + + /** + * Creates and pre-populates an authentication session for OIDC browser continuation. + */ + private AuthenticationSessionModel createBrowserAuthenticationSession(RealmModel realm, ClientModel client, UserModel user, String redirectUri) { + AuthenticationSessionManager authenticationSessionManager = new AuthenticationSessionManager(session); + RootAuthenticationSessionModel rootAuthenticationSession = authenticationSessionManager.createAuthenticationSession(realm, true); + AuthenticationSessionModel authenticationSession = rootAuthenticationSession.createAuthenticationSession(client); + + authenticationSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + authenticationSession.setAction(CommonClientSessionModel.Action.AUTHENTICATE.name()); + authenticationSession.setAuthenticatedUser(user); + authenticationSession.setRedirectUri(redirectUri); + authenticationSession.setClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM, OIDCResponseType.NONE); + authenticationSession.setClientNote(OIDCLoginProtocol.RESPONSE_MODE_PARAM, RESPONSE_MODE_QUERY); + authenticationSession.setClientNote(OIDCLoginProtocol.REDIRECT_URI_PARAM, redirectUri); + authenticationSession.setClientNote(OIDCLoginProtocol.SCOPE_PARAM, resolveScopeParameter(client)); + AuthenticationManager.setClientScopesInSession(session, authenticationSession); + authenticationSession.setClientNote(OIDCLoginProtocol.STATE_PARAM, generateState()); + authenticationSession.setClientNote( + OIDCLoginProtocol.ISSUER, + Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()) + ); + return authenticationSession; + } + + /** + * Derives scope to place into the auth session; falls back to {@code openid}. + */ + private String resolveScopeParameter(ClientModel client) { + return PasskeyConfigResolver.firstNonBlank( + AuthenticationManager.getRequestedScopes(session, client), + OAuth2Constants.SCOPE_OPENID + ); + } + + /** + * Generates a random state value used by the browser flow. + */ + private String generateState() { + Challenge challenge = new DefaultChallenge(); + return Base64Url.encode(challenge.getValue()); + } + + /** + * Finds the first client-validated redirect URI from request hints and configured fallbacks. + */ + private String resolveValidatedRedirectUri(ClientModel client) { + List redirectCandidates = new ArrayList<>(); + redirectCandidates.add(normalizeRedirectCandidate(getHeaderValue(HEADER_REFERER), true)); + redirectCandidates.add(normalizeRedirectCandidate(getHeaderValue(HEADER_ORIGIN), false)); + redirectCandidates.add(normalizeRedirectCandidate(client.getBaseUrl(), true)); + + for (String redirectCandidate : redirectCandidates) { + if (redirectCandidate == null) { + continue; + } + String verifiedRedirectUri = RedirectUtils.verifyRedirectUri(session, redirectCandidate, client); + if (verifiedRedirectUri != null) { + return verifiedRedirectUri; + } + } + + String singleConfiguredRedirect = RedirectUtils.verifyRedirectUri(session, null, client, false); + if (singleConfiguredRedirect != null) { + return singleConfiguredRedirect; + } + + for (String configuredRedirectUri : client.getRedirectUris()) { + if (configuredRedirectUri == null || configuredRedirectUri.isBlank() || configuredRedirectUri.contains("*")) { + continue; + } + String verifiedRedirectUri = RedirectUtils.verifyRedirectUri(session, configuredRedirectUri, client); + if (verifiedRedirectUri != null) { + return verifiedRedirectUri; + } + } + + return null; + } + + /** + * Reads a header value from the current request. + */ + private String getHeaderValue(String headerName) { + var headers = session.getContext().getRequestHeaders(); + if (headers == null) { + return null; + } + return headers.getHeaderString(headerName); + } + + /** + * Normalizes a redirect candidate to a stable URI form before validation. + */ + private String normalizeRedirectCandidate(String rawUri, boolean preservePath) { + if (rawUri == null || rawUri.isBlank()) { + return null; + } + + try { + URI parsedUri = URI.create(rawUri.trim()); + if (parsedUri.getScheme() == null || parsedUri.getHost() == null) { + return null; + } + + if (!preservePath) { + return UriUtils.getOrigin(parsedUri); + } + + String path = parsedUri.getPath(); + if (path == null || path.isBlank()) { + path = "/"; + } + + URI normalizedUri = new URI( + parsedUri.getScheme(), + parsedUri.getUserInfo(), + parsedUri.getHost(), + parsedUri.getPort(), + path, + null, + null + ); + return normalizedUri.toString(); + } catch (Exception ignored) { + return null; + } + } + +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyChallengeService.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyChallengeService.java new file mode 100644 index 00000000..996ad796 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyChallengeService.java @@ -0,0 +1,165 @@ +package com.example.keycloak; + +import com.webauthn4j.data.client.challenge.Challenge; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import org.keycloak.common.util.Base64Url; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.services.managers.AuthenticationSessionManager; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.sessions.RootAuthenticationSessionModel; + +import java.util.Objects; + +final class PasskeyChallengeService { + + private static final long CHALLENGE_TTL_MILLIS = 120_000; + private static final String AUTH_NOTE_PASSKEY_CHALLENGE = "passkey.challenge"; + private static final String AUTH_NOTE_PASSKEY_CHALLENGE_ISSUED_AT = "passkey.challenge.issuedAt"; + + private final KeycloakSession session; + private final PasskeyClientSupport clientSupport; + + /** + * Creates a challenge service bound to the current request session. + * + * @param session Keycloak request session + * @param clientSupport helper for resolving configured client + */ + PasskeyChallengeService(KeycloakSession session, PasskeyClientSupport clientSupport) { + this.session = session; + this.clientSupport = clientSupport; + } + + /** + * Generates and stores a single-use passkey challenge in authentication-session notes. + * + * @return base64url encoded challenge value + */ + String issueChallenge() { + RealmModel realm = requireRealm(); + ClientModel client = clientSupport.requireConfiguredClient(realm); + AuthenticationSessionModel challengeSession = getOrCreateChallengeSession(realm, client); + String challenge = generateChallenge(); + challengeSession.setAuthNote(AUTH_NOTE_PASSKEY_CHALLENGE, challenge); + challengeSession.setAuthNote(AUTH_NOTE_PASSKEY_CHALLENGE_ISSUED_AT, Long.toString(System.currentTimeMillis())); + return challenge; + } + + /** + * Consumes a challenge if it matches and is still within the configured TTL. + * + * @param challenge challenge sent by client + * @return {@code true} when challenge exists, matches, and is not expired + */ + boolean consumeChallenge(String challenge) { + if (challenge == null || challenge.isBlank()) { + return false; + } + + RealmModel realm = session.getContext().getRealm(); + if (realm == null) { + return false; + } + + ClientModel client = clientSupport.resolveConfiguredClient(realm); + if (client == null) { + return false; + } + + AuthenticationSessionModel challengeSession = resolveChallengeSession(realm, client); + if (challengeSession == null) { + return false; + } + + String trackedChallenge = challengeSession.getAuthNote(AUTH_NOTE_PASSKEY_CHALLENGE); + if (!Objects.equals(challenge, trackedChallenge)) { + return false; + } + + challengeSession.removeAuthNote(AUTH_NOTE_PASSKEY_CHALLENGE); + String issuedAtRaw = challengeSession.getAuthNote(AUTH_NOTE_PASSKEY_CHALLENGE_ISSUED_AT); + challengeSession.removeAuthNote(AUTH_NOTE_PASSKEY_CHALLENGE_ISSUED_AT); + return isWithinTtl(issuedAtRaw); + } + + /** + * Returns the current realm or throws when realm context is missing. + */ + private RealmModel requireRealm() { + RealmModel realm = session.getContext().getRealm(); + if (realm == null) { + throw new IllegalStateException("Realm context is unavailable"); + } + return realm; + } + + /** + * Verifies whether the stored issue timestamp is still valid. + */ + private boolean isWithinTtl(String issuedAtRaw) { + try { + long issuedAt = Long.parseLong(PasskeyConfigResolver.firstNonBlank(issuedAtRaw, "0")); + return issuedAt > 0 && (System.currentTimeMillis() - issuedAt) <= CHALLENGE_TTL_MILLIS; + } catch (NumberFormatException ignored) { + return false; + } + } + + /** + * Produces a random base64url challenge string. + */ + private String generateChallenge() { + Challenge challenge = new DefaultChallenge(); + return Base64Url.encode(challenge.getValue()); + } + + /** + * Returns an existing challenge session for the client, or creates a new one. + */ + private AuthenticationSessionModel getOrCreateChallengeSession(RealmModel realm, ClientModel client) { + AuthenticationSessionModel existingSession = resolveChallengeSession(realm, client); + if (existingSession != null) { + return existingSession; + } + + AuthenticationSessionManager authenticationSessionManager = new AuthenticationSessionManager(session); + RootAuthenticationSessionModel rootAuthenticationSession = authenticationSessionManager.createAuthenticationSession(realm, true); + AuthenticationSessionModel authenticationSession = rootAuthenticationSession.createAuthenticationSession(client); + authenticationSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + return authenticationSession; + } + + /** + * Resolves the challenge session associated with the configured client. + */ + private AuthenticationSessionModel resolveChallengeSession(RealmModel realm, ClientModel client) { + AuthenticationSessionModel contextAuthenticationSession = session.getContext().getAuthenticationSession(); + if (isSessionForClient(contextAuthenticationSession, client)) { + return contextAuthenticationSession; + } + + AuthenticationSessionManager authenticationSessionManager = new AuthenticationSessionManager(session); + RootAuthenticationSessionModel rootAuthenticationSession = authenticationSessionManager.getCurrentRootAuthenticationSession(realm); + if (rootAuthenticationSession == null) { + return null; + } + + return rootAuthenticationSession.getAuthenticationSessions().values().stream() + .filter(authenticationSession -> isSessionForClient(authenticationSession, client)) + .findFirst() + .orElse(null); + } + + /** + * Checks whether an authentication session belongs to the given client. + */ + private boolean isSessionForClient(AuthenticationSessionModel authenticationSession, ClientModel client) { + return authenticationSession != null + && client != null + && authenticationSession.getClient() != null + && Objects.equals(authenticationSession.getClient().getId(), client.getId()); + } +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyClientSupport.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyClientSupport.java new file mode 100644 index 00000000..6203cbf7 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyClientSupport.java @@ -0,0 +1,45 @@ +package com.example.keycloak; + +import org.keycloak.models.ClientModel; +import org.keycloak.models.RealmModel; + +final class PasskeyClientSupport { + + private final String clientId; + + /** + * Creates a helper for resolving the configured passkey client. + * + * @param clientId configured client identifier + */ + PasskeyClientSupport(String clientId) { + this.clientId = clientId; + } + + /** + * Resolves the configured client in the given realm. + * + * @param realm current realm + * @return matching client, or {@code null} when configuration/context is incomplete + */ + ClientModel resolveConfiguredClient(RealmModel realm) { + if (realm == null || clientId == null || clientId.isBlank()) { + return null; + } + return realm.getClientByClientId(clientId); + } + + /** + * Resolves the configured client and throws when it cannot be found. + * + * @param realm current realm + * @return configured client model + */ + ClientModel requireConfiguredClient(RealmModel realm) { + ClientModel client = resolveConfiguredClient(realm); + if (client == null) { + throw new IllegalStateException("Client not found for configured passkey client_id"); + } + return client; + } +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyConfigResolver.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyConfigResolver.java new file mode 100644 index 00000000..ca7ed104 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyConfigResolver.java @@ -0,0 +1,59 @@ +package com.example.keycloak; + +import org.keycloak.Config; + +final class PasskeyConfigResolver { + + private static final String DEFAULT_ALLOWED_ORIGIN = "http://localhost:3000"; + private static final String DEFAULT_CLIENT_ID = "demo-app"; + private static final String ENV_ALLOWED_BROWSER_ORIGIN = "KC_ALLOWED_BROWSER_ORIGIN"; + private static final String ENV_DEMO_ADDITIONAL_WEB_ORIGIN = "KC_DEMO_ADDITIONAL_WEB_ORIGIN"; + private static final String ENV_PASSKEY_CLIENT_ID = "KC_PASSKEY_CLIENT_ID"; + + private PasskeyConfigResolver() { + } + + static String resolveAllowedOriginPattern(Config.Scope config) { + return firstNonBlank( + config == null ? null : config.get("allowed-browser-origin"), + System.getenv(ENV_ALLOWED_BROWSER_ORIGIN), + System.getenv(ENV_DEMO_ADDITIONAL_WEB_ORIGIN), + DEFAULT_ALLOWED_ORIGIN + ); + } + + static String resolveClientId(Config.Scope config) { + return firstNonBlank( + config == null ? null : config.get("client-id"), + System.getenv(ENV_PASSKEY_CLIENT_ID), + DEFAULT_CLIENT_ID + ); + } + + static String resolveAllowedOriginPatternFromEnv() { + return firstNonBlank( + System.getenv(ENV_ALLOWED_BROWSER_ORIGIN), + System.getenv(ENV_DEMO_ADDITIONAL_WEB_ORIGIN), + DEFAULT_ALLOWED_ORIGIN + ); + } + + static String resolveClientIdFromEnv() { + return firstNonBlank( + System.getenv(ENV_PASSKEY_CLIENT_ID), + DEFAULT_CLIENT_ID + ); + } + + static String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyRequest.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyRequest.java new file mode 100644 index 00000000..603edadb --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyRequest.java @@ -0,0 +1,35 @@ +package com.example.keycloak; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PasskeyRequest { + + @JsonProperty("credentialId") + private String credentialId; + + @JsonProperty("rawId") + private String rawId; + + @JsonProperty("attestationObject") + private String attestationObject; + + @JsonProperty("clientDataJSON") + private String clientDataJSON; + + @JsonProperty("authenticatorData") + private String authenticatorData; + + @JsonProperty("signature") + private String signature; + + @JsonProperty("challenge") + private String challenge; +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyWebAuthnService.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyWebAuthnService.java new file mode 100644 index 00000000..dd73d720 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyWebAuthnService.java @@ -0,0 +1,382 @@ +package com.example.keycloak; + +import com.webauthn4j.WebAuthnRegistrationManager; +import com.webauthn4j.converter.exception.DataConversionException; +import com.webauthn4j.converter.util.ObjectConverter; +import com.webauthn4j.data.AuthenticationRequest; +import com.webauthn4j.data.RegistrationData; +import com.webauthn4j.data.RegistrationParameters; +import com.webauthn4j.data.RegistrationRequest; +import com.webauthn4j.data.client.Origin; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import com.webauthn4j.server.ServerProperty; +import com.webauthn4j.verifier.exception.VerificationException; +import org.keycloak.common.util.Base64Url; +import org.keycloak.common.util.UriUtils; +import org.keycloak.credential.CredentialModel; +import org.keycloak.credential.CredentialProvider; +import org.keycloak.credential.WebAuthnCredentialModelInput; +import org.keycloak.credential.WebAuthnCredentialProvider; +import org.keycloak.credential.WebAuthnPasswordlessCredentialProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.WebAuthnPolicy; +import org.keycloak.models.credential.WebAuthnCredentialModel; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +final class PasskeyWebAuthnService { + + private static final String PASSKEY_TYPE = WebAuthnCredentialModel.TYPE_PASSWORDLESS; + private static final String CREDENTIAL_USER_ATTR = "passkey-credential-id"; + private static final String HEADER_ORIGIN = "Origin"; + + private final KeycloakSession session; + private final Pattern allowedBrowserOrigin; + + /** + * Creates WebAuthn helper logic bound to the current request. + * + * @param session Keycloak request session + * @param allowedBrowserOrigin compiled allowed browser origin pattern + */ + PasskeyWebAuthnService(KeycloakSession session, Pattern allowedBrowserOrigin) { + this.session = session; + this.allowedBrowserOrigin = allowedBrowserOrigin; + } + + /** + * Resolves credential identifier from request fields. + * + * @param request passkey request payload + * @return credential id, preferring {@code credentialId} over {@code rawId} + */ + String resolveCredentialId(PasskeyRequest request) { + return PasskeyConfigResolver.firstNonBlank(request.getCredentialId(), request.getRawId()); + } + + /** + * Finds a user by normalized credential id mapping stored as user attribute. + * + * @param realm current realm + * @param credentialId credential id from client + * @return matching user or {@code null} when none exists + */ + UserModel findUserByCredentialId(RealmModel realm, String credentialId) { + String normalizedCredentialId = normalizeCredentialId(credentialId); + if (normalizedCredentialId == null) { + return null; + } + + return session.users() + .searchForUserByUserAttributeStream(realm, CREDENTIAL_USER_ATTR, normalizedCredentialId) + .findFirst() + .orElse(null); + } + + /** + * Checks whether the user has a stored passwordless credential matching the requested id. + * + * @param user target user + * @param credentialId credential id from client + * @return {@code true} when a matching passkey credential exists + */ + boolean hasPasskeyCredential(UserModel user, String credentialId) { + byte[] requestedCredentialId = credentialIdToBytes(credentialId); + if (requestedCredentialId.length == 0) { + return false; + } + + return user.credentialManager() + .getStoredCredentialsByTypeStream(PASSKEY_TYPE) + .map(WebAuthnCredentialModel::createFromCredentialModel) + .map(WebAuthnCredentialModel::getWebAuthnCredentialData) + .filter(Objects::nonNull) + .map(data -> data.getCredentialId()) + .filter(Objects::nonNull) + .map(this::credentialIdToBytes) + .anyMatch(storedCredentialId -> Arrays.equals(storedCredentialId, requestedCredentialId)); + } + + /** + * Validates and stores a new passkey credential through Keycloak's credential provider. + * + * @param user target user + * @param request registration payload + * @param expectedChallenge challenge issued by this service + */ + void registerPasskey(UserModel user, PasskeyRequest request, String expectedChallenge) { + RealmModel realm = requireRealm(); + RegistrationRequest registrationRequest = new RegistrationRequest( + decodeRequiredBase64Url(request.getAttestationObject(), "attestationObject"), + decodeRequiredBase64Url(request.getClientDataJSON(), "clientDataJSON") + ); + RegistrationParameters registrationParameters = new RegistrationParameters( + buildServerProperty(realm, expectedChallenge), + isUserVerificationRequired(realm) + ); + + RegistrationData registrationData = validateRegistration(registrationRequest, registrationParameters); + WebAuthnCredentialProvider provider = getPasswordlessCredentialProvider(); + WebAuthnCredentialModelInput credentialInput = createCredentialInput(registrationData); + WebAuthnCredentialModel credentialModel = provider.getCredentialModelFromCredentialInput(credentialInput, user.getUsername()); + CredentialModel storedCredentialModel = user.credentialManager().createStoredCredential(credentialModel); + if (storedCredentialModel == null) { + throw new IllegalStateException("Failed to store passkey credential"); + } + WebAuthnCredentialModel storedCredential = WebAuthnCredentialModel.createFromCredentialModel(storedCredentialModel); + if (storedCredential.getWebAuthnCredentialData() != null) { + storeCredentialUserMapping(user, storedCredential.getWebAuthnCredentialData().getCredentialId()); + } + } + + /** + * Validates a WebAuthn assertion using Keycloak credential-manager validation. + * + * @param user target user + * @param request authentication payload + * @param credentialId resolved credential id + * @return {@code true} when assertion is valid + */ + boolean authenticatePasskey(UserModel user, PasskeyRequest request, String credentialId) { + RealmModel realm = requireRealm(); + AuthenticationRequest authenticationRequest = new AuthenticationRequest( + decodeRequiredBase64Url(credentialId, "credentialId"), + decodeRequiredBase64Url(request.getAuthenticatorData(), "authenticatorData"), + decodeRequiredBase64Url(request.getClientDataJSON(), "clientDataJSON"), + decodeRequiredBase64Url(request.getSignature(), "signature") + ); + + WebAuthnCredentialModelInput credentialInput = new WebAuthnCredentialModelInput(PASSKEY_TYPE); + credentialInput.setAuthenticationRequest(authenticationRequest); + credentialInput.setAuthenticationParameters( + new WebAuthnCredentialModelInput.KeycloakWebAuthnAuthenticationParameters( + buildServerProperty(realm, request.getChallenge()), + isUserVerificationRequired(realm) + ) + ); + + return user.credentialManager().isValid(credentialInput); + } + + /** + * Builds the registration manager used to validate attestation payloads. + */ + protected WebAuthnRegistrationManager createWebAuthnRegistrationManager() { + return WebAuthnRegistrationManager.createNonStrictWebAuthnRegistrationManager(new ObjectConverter()); + } + + /** + * Parses and validates registration payload data. + */ + private RegistrationData validateRegistration(RegistrationRequest request, RegistrationParameters parameters) { + try { + return createWebAuthnRegistrationManager().verify(request, parameters); + } catch (DataConversionException | VerificationException e) { + throw new IllegalArgumentException("Passkey registration validation failed: " + e.getMessage(), e); + } + } + + /** + * Maps parsed registration data into Keycloak's credential input model. + */ + private WebAuthnCredentialModelInput createCredentialInput(RegistrationData registrationData) { + WebAuthnCredentialModelInput credentialInput = new WebAuthnCredentialModelInput(PASSKEY_TYPE); + credentialInput.setAttestedCredentialData(registrationData.getAttestationObject().getAuthenticatorData().getAttestedCredentialData()); + credentialInput.setCount(registrationData.getAttestationObject().getAuthenticatorData().getSignCount()); + credentialInput.setAttestationStatementFormat(registrationData.getAttestationObject().getFormat()); + credentialInput.setTransports(registrationData.getTransports()); + return credentialInput; + } + + /** + * Resolves the passwordless WebAuthn credential provider. + */ + private WebAuthnCredentialProvider getPasswordlessCredentialProvider() { + WebAuthnCredentialProvider provider = (WebAuthnCredentialProvider) session.getProvider( + CredentialProvider.class, + WebAuthnPasswordlessCredentialProviderFactory.PROVIDER_ID + ); + if (provider == null) { + throw new IllegalStateException("Passwordless WebAuthn credential provider is unavailable"); + } + return provider; + } + + /** + * Constructs server property used by WebAuthn registration/authentication validation. + */ + private ServerProperty buildServerProperty(RealmModel realm, String challenge) { + if (challenge == null || challenge.isBlank()) { + throw new IllegalArgumentException("challenge is required"); + } + return new ServerProperty( + resolveAllowedOrigins(realm), + resolveRequiredRpId(realm), + new DefaultChallenge(challenge), + null + ); + } + + /** + * Resolves accepted origins from request origin and passwordless extra-origin policy entries. + */ + private Set resolveAllowedOrigins(RealmModel realm) { + WebAuthnPolicy policy = requirePasswordlessPolicy(realm); + Set origins = new HashSet<>(); + origins.add(new Origin(requireAllowedOrigin())); + + List extraOrigins = policy.getExtraOrigins(); + if (extraOrigins == null) { + return origins; + } + + for (String extraOrigin : extraOrigins) { + if (extraOrigin == null || extraOrigin.isBlank()) { + continue; + } + String normalizedExtraOrigin = normalizeOrigin(extraOrigin.trim()); + origins.add(new Origin(normalizedExtraOrigin)); + } + return origins; + } + + /** + * Extracts and validates the request {@code Origin} header against configured regex. + */ + private String requireAllowedOrigin() { + var headers = session.getContext().getRequestHeaders(); + String originHeader = headers == null ? null : headers.getHeaderString(HEADER_ORIGIN); + if (originHeader == null || originHeader.isBlank()) { + throw new IllegalArgumentException("Origin header is required"); + } + + String origin = normalizeOrigin(originHeader.trim()); + if (!allowedBrowserOrigin.matcher(origin).matches()) { + throw new IllegalArgumentException("Origin is not allowed"); + } + return origin; + } + + /** + * Normalizes and validates origin representation. + */ + private String normalizeOrigin(String candidateOrigin) { + try { + String origin = UriUtils.getOrigin(candidateOrigin); + if (origin == null || !UriUtils.isOrigin(origin)) { + throw new IllegalArgumentException("Invalid origin: " + candidateOrigin); + } + return origin; + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid origin: " + candidateOrigin, e); + } + } + + /** + * Resolves RP ID from passwordless policy, falling back to base URI host. + */ + private String resolveRequiredRpId(RealmModel realm) { + WebAuthnPolicy policy = requirePasswordlessPolicy(realm); + String fallbackRpId = session.getContext().getUri() == null || session.getContext().getUri().getBaseUri() == null + ? null + : session.getContext().getUri().getBaseUri().getHost(); + String rpId = PasskeyConfigResolver.firstNonBlank(policy.getRpId(), fallbackRpId); + if (rpId == null || rpId.isBlank()) { + throw new IllegalStateException("Passwordless WebAuthn RP ID is not configured"); + } + return rpId.trim(); + } + + /** + * Determines whether user verification is mandatory for this realm policy. + */ + private boolean isUserVerificationRequired(RealmModel realm) { + String uvRequirement = requirePasswordlessPolicy(realm).getUserVerificationRequirement(); + return uvRequirement != null && "required".equalsIgnoreCase(uvRequirement); + } + + /** + * Returns passwordless WebAuthn policy or throws when absent. + */ + private WebAuthnPolicy requirePasswordlessPolicy(RealmModel realm) { + if (realm == null || realm.getWebAuthnPolicyPasswordless() == null) { + throw new IllegalStateException("Passwordless WebAuthn policy is not configured"); + } + return realm.getWebAuthnPolicyPasswordless(); + } + + /** + * Decodes a required base64url field from client payload. + */ + private byte[] decodeRequiredBase64Url(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " is required"); + } + try { + return Base64Url.decode(value); + } catch (RuntimeException e) { + throw new IllegalArgumentException(field + " must be a valid base64url value", e); + } + } + + /** + * Converts arbitrary credential id input into canonical base64url form. + */ + private String normalizeCredentialId(String credentialId) { + byte[] credentialBytes = credentialIdToBytes(credentialId); + if (credentialBytes.length == 0) { + return null; + } + return Base64Url.encode(credentialBytes); + } + + /** + * Decodes credential id into bytes, returning empty bytes on invalid input. + */ + private byte[] credentialIdToBytes(String credentialId) { + if (credentialId == null || credentialId.isBlank()) { + return new byte[0]; + } + + try { + return Base64Url.decode(credentialId); + } catch (RuntimeException ignored) { + return new byte[0]; + } + } + + /** + * Returns the current realm or throws when request context has none. + */ + private RealmModel requireRealm() { + RealmModel realm = session.getContext().getRealm(); + if (realm == null) { + throw new IllegalStateException("Realm context is unavailable"); + } + return realm; + } + + /** + * Persists credential-to-user mapping used by direct credential-id lookup. + */ + private void storeCredentialUserMapping(UserModel user, String credentialId) { + String normalizedCredentialId = normalizeCredentialId(credentialId); + if (normalizedCredentialId == null) { + return; + } + + List values = new ArrayList<>(user.getAttributeStream(CREDENTIAL_USER_ATTR).toList()); + if (!values.contains(normalizedCredentialId)) { + values.add(normalizedCredentialId); + user.setAttribute(CREDENTIAL_USER_ATTR, values); + } + } +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProvider.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProvider.java new file mode 100644 index 00000000..7d3c73b2 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProvider.java @@ -0,0 +1,26 @@ +package com.example.keycloak; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.services.resource.RealmResourceProvider; + +public class UserPasskeyProvider implements RealmResourceProvider { + + private final KeycloakSession session; + private final String allowedBrowserOriginPattern; + private final String clientId; + + public UserPasskeyProvider(KeycloakSession session, String allowedBrowserOriginPattern, String clientId) { + this.session = session; + this.allowedBrowserOriginPattern = allowedBrowserOriginPattern; + this.clientId = clientId; + } + + @Override + public Object getResource() { + return new UserPasskeyResource(session, allowedBrowserOriginPattern, clientId); + } + + @Override + public void close() { + } +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProviderFactory.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProviderFactory.java new file mode 100644 index 00000000..cfb9eae0 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProviderFactory.java @@ -0,0 +1,37 @@ +package com.example.keycloak; + +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.services.resource.RealmResourceProvider; +import org.keycloak.services.resource.RealmResourceProviderFactory; + +public class UserPasskeyProviderFactory implements RealmResourceProviderFactory { + + public static final String ID = "passkey"; + private String allowedBrowserOriginPattern; + private String clientId; + + @Override + public String getId() { + return ID; + } + + @Override + public RealmResourceProvider create(KeycloakSession session) { + return new UserPasskeyProvider(session, allowedBrowserOriginPattern, clientId); + } + + @Override + public void init(Config.Scope config) { + allowedBrowserOriginPattern = PasskeyConfigResolver.resolveAllowedOriginPattern(config); + clientId = PasskeyConfigResolver.resolveClientId(config); + } + + @Override + public void postInit(KeycloakSessionFactory factory) {} + + @Override + public void close() {} + +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java new file mode 100644 index 00000000..339b36b4 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java @@ -0,0 +1,338 @@ +package com.example.keycloak; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotAuthorizedException; +import jakarta.ws.rs.OPTIONS; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.jboss.logging.Logger; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.services.ErrorResponse; +import org.keycloak.services.ErrorResponseException; +import org.keycloak.services.cors.Cors; +import org.keycloak.services.managers.AppAuthManager; +import org.keycloak.services.managers.AuthenticationManager; + +import java.util.Map; +import java.util.regex.Pattern; + +@Path("/") +public class UserPasskeyResource { + + private static final Logger logger = Logger.getLogger(UserPasskeyResource.class); + private static final String ERROR_SERVER_CONFIGURATION = "Server configuration error"; + private static final String ERROR_INVALID_OR_EXPIRED_CHALLENGE = "Invalid or expired challenge"; + private static final String ERROR_REQUEST_BODY_REQUIRED = "Request body is required"; + + private final KeycloakSession session; + private final Pattern allowedBrowserOrigin; + private final String clientId; + private final PasskeyClientSupport clientSupport; + + /** + * Default constructor for CDI environments that require a no-arg constructor. + *

+ * The SPI-managed constructor should be used for normal runtime operation. + */ + public UserPasskeyResource() { + this.session = null; + this.allowedBrowserOrigin = Pattern.compile("^" + PasskeyConfigResolver.resolveAllowedOriginPatternFromEnv() + "$"); + this.clientId = PasskeyConfigResolver.resolveClientIdFromEnv(); + this.clientSupport = new PasskeyClientSupport(this.clientId); + } + + /** + * Creates the passkey resource with SPI-provided session and resolved configuration. + * + * @param session Keycloak request session + * @param allowedOriginPattern configured allowed browser origin regex (without anchors) + * @param clientId configured OIDC client identifier used for passkey flows + */ + public UserPasskeyResource(KeycloakSession session, String allowedOriginPattern, String clientId) { + this.session = session; + String configuredPattern = PasskeyConfigResolver.firstNonBlank( + allowedOriginPattern, + PasskeyConfigResolver.resolveAllowedOriginPatternFromEnv() + ); + this.allowedBrowserOrigin = Pattern.compile("^" + configuredPattern + "$"); + this.clientId = PasskeyConfigResolver.firstNonBlank( + clientId, + PasskeyConfigResolver.resolveClientIdFromEnv() + ); + this.clientSupport = new PasskeyClientSupport(this.clientId); + } + + /** + * Handles browser CORS preflight requests for all passkey endpoints. + * + * @return preflight response with CORS headers when client configuration is available + */ + @OPTIONS + @Path("{any:.*}") + public Response corsPreflight() { + Response.ResponseBuilder responseBuilder = Response.ok(); + applyCors(responseBuilder, true); + return responseBuilder.build(); + } + + /** + * Issues a short-lived challenge used by registration and authentication requests. + * + * @return JSON object containing a base64url challenge value + */ + @GET + @Path("challenge") + @Produces(MediaType.APPLICATION_JSON) + public Response getChallenge() { + try { + return jsonOk(Map.of("challenge", challengeService().issueChallenge())); + } catch (IllegalStateException e) { + return handleServerConfigurationError("Passkey challenge creation failed due to server configuration", e); + } + } + + /** + * Stores a new passwordless WebAuthn credential for the authenticated bearer token user. + * + * @param request passkey registration payload + * @return created response on success, error response otherwise + */ + @POST + @Path("save") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response savePasskey(PasskeyRequest request) { + if (request == null) { + return buildErrorResponse(Response.Status.BAD_REQUEST, ERROR_REQUEST_BODY_REQUIRED); + } + + UserModel user = getUserFromBearerToken(); + if (user == null) { + return textResponse(Response.Status.UNAUTHORIZED, "Authenticated user not found from access token"); + } + + if (!challengeService().consumeChallenge(request.getChallenge())) { + return buildErrorResponse(Response.Status.UNAUTHORIZED, ERROR_INVALID_OR_EXPIRED_CHALLENGE); + } + + try { + webAuthnService().registerPasskey(user, request, request.getChallenge()); + return textResponse(Response.Status.CREATED, "Passkey stored successfully"); + } catch (IllegalArgumentException e) { + return buildErrorResponse(Response.Status.BAD_REQUEST, "Invalid registration payload: " + e.getMessage()); + } catch (IllegalStateException e) { + return handleServerConfigurationError("Passkey registration failed due to server configuration", e); + } + } + + /** + * Verifies a passkey assertion and continues the regular Keycloak browser login flow. + * + * @param request passkey authentication payload + * @return browser flow response on success, error response otherwise + */ + @POST + @Path("authenticate") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response authenticatePasskey(PasskeyRequest request) { + if (request == null) { + return buildErrorResponse(Response.Status.BAD_REQUEST, ERROR_REQUEST_BODY_REQUIRED); + } + + RealmModel realm = session().getContext().getRealm(); + if (realm == null) { + return handleServerConfigurationError("Realm context unavailable for passkey authentication", new IllegalStateException("Realm context is unavailable")); + } + + String requestCredentialId = webAuthnService().resolveCredentialId(request); + if (requestCredentialId == null) { + return buildErrorResponse(Response.Status.BAD_REQUEST, "credentialId or rawId is required"); + } + + if (!challengeService().consumeChallenge(request.getChallenge())) { + return buildErrorResponse(Response.Status.UNAUTHORIZED, ERROR_INVALID_OR_EXPIRED_CHALLENGE); + } + + UserModel user = webAuthnService().findUserByCredentialId(realm, requestCredentialId); + if (user == null) { + return buildErrorResponse(Response.Status.NOT_FOUND, "User not found for credential"); + } + + if (!webAuthnService().hasPasskeyCredential(user, requestCredentialId)) { + return buildErrorResponse(Response.Status.NOT_FOUND, "No passkey found for user: " + user.getUsername()); + } + + try { + if (!webAuthnService().authenticatePasskey(user, request, requestCredentialId)) { + return buildErrorResponse(Response.Status.UNAUTHORIZED, "Invalid passkey"); + } + + return withCors(browserLoginService().completeLogin(user, realm)); + } catch (IllegalArgumentException e) { + return buildErrorResponse(Response.Status.BAD_REQUEST, "Invalid authentication payload: " + e.getMessage()); + } catch (IllegalStateException e) { + return handleServerConfigurationError("Passkey authentication failed due to server configuration", e); + } catch (Exception e) { + logger.error("Browser-flow completion after passkey authentication failed: " + e.getMessage(), e); + return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Authentication flow failed"); + } + } + + /** + * Resolves the current user from the bearer token in request headers using Keycloak's authenticator. + * + * @return authenticated user, or {@code null} when token validation fails + */ + private UserModel getUserFromBearerToken() { + RealmModel realm = session().getContext().getRealm(); + if (realm == null) { + return null; + } + + AuthenticationManager.AuthResult authResult; + try { + authResult = new AppAuthManager.BearerTokenAuthenticator(session()) + .setRealm(realm) + .setConnection(session().getContext().getConnection()) + .setUriInfo(session().getContext().getUri()) + .setHeaders(session().getContext().getRequestHeaders()) + .authenticate(); + } catch (NotAuthorizedException ignored) { + return null; + } catch (RuntimeException ignored) { + return null; + } + + if (authResult == null || authResult.getUser() == null) { + return null; + } + + if (clientId != null && !clientId.isBlank()) { + ClientModel tokenClient = authResult.getClient(); + if (tokenClient == null || !clientId.equals(tokenClient.getClientId())) { + return null; + } + } + + return authResult.getUser(); + } + + /** + * Returns the active SPI session and fails fast if the resource was created without it. + */ + private KeycloakSession session() { + if (session == null) { + throw new IllegalStateException("UserPasskeyResource must be created by UserPasskeyProvider (SPI-managed session required)."); + } + return session; + } + + /** + * Creates the challenge service for the current request context. + */ + private PasskeyChallengeService challengeService() { + return new PasskeyChallengeService(session(), clientSupport); + } + + /** + * Creates the WebAuthn service for the current request context. + */ + private PasskeyWebAuthnService webAuthnService() { + return new PasskeyWebAuthnService(session(), allowedBrowserOrigin); + } + + /** + * Creates the browser-login service for the current request context. + */ + private PasskeyBrowserLoginService browserLoginService() { + return new PasskeyBrowserLoginService(session(), clientSupport); + } + + /** + * Logs and returns a uniform internal-server-error response for configuration problems. + */ + private Response handleServerConfigurationError(String logMessage, IllegalStateException exception) { + logger.error(logMessage, exception); + return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, ERROR_SERVER_CONFIGURATION); + } + + /** + * Builds a 200 JSON response wrapped with CORS headers. + */ + private Response jsonOk(Object payload) { + return jsonResponse(Response.Status.OK, payload); + } + + /** + * Builds a JSON response with CORS headers. + */ + private Response jsonResponse(Response.Status status, Object payload) { + Response.ResponseBuilder builder = status == Response.Status.OK + ? Response.ok(payload) + : Response.status(status).entity(payload); + return withCors(builder.type(MediaType.APPLICATION_JSON_TYPE).build()); + } + + /** + * Builds a plain-text response with CORS headers. + */ + private Response textResponse(Response.Status status, String payload) { + return withCors(Response.status(status) + .entity(payload) + .type(MediaType.TEXT_PLAIN_TYPE) + .build()); + } + + /** + * Builds a Keycloak-style error response and applies CORS headers. + */ + private Response buildErrorResponse(Response.Status status, String message) { + ErrorResponseException errorResponse = ErrorResponse.error( + PasskeyConfigResolver.firstNonBlank(message, ""), + status + ); + return withCors(errorResponse.getResponse()); + } + + /** + * Rebuilds the response while appending CORS headers for configured clients. + */ + private Response withCors(Response response) { + Response.ResponseBuilder responseBuilder = Response.fromResponse(response); + applyCors(responseBuilder, false); + return responseBuilder.build(); + } + + /** + * Applies Keycloak CORS settings based on configured client web origins. + * + * @param responseBuilder response builder to mutate + * @param preflight whether to apply preflight-specific headers + */ + private void applyCors(Response.ResponseBuilder responseBuilder, boolean preflight) { + ClientModel corsClient = clientSupport.resolveConfiguredClient(session().getContext().getRealm()); + if (corsClient == null) { + return; + } + + Cors cors = Cors.builder() + .builder(responseBuilder) + .auth() + .allowedMethods("GET", "POST", "OPTIONS"); + + if (preflight) { + cors.preflight(); + } + + cors.allowedOrigins(session(), corsClient); + cors.add(); + } +} diff --git a/keycloak-extension-passkey/src/main/resources/META-INF/beans.xml b/keycloak-extension-passkey/src/main/resources/META-INF/beans.xml new file mode 100644 index 00000000..ded49af4 --- /dev/null +++ b/keycloak-extension-passkey/src/main/resources/META-INF/beans.xml @@ -0,0 +1,4 @@ + + diff --git a/keycloak-extension-passkey/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory b/keycloak-extension-passkey/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory new file mode 100644 index 00000000..285e6994 --- /dev/null +++ b/keycloak-extension-passkey/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory @@ -0,0 +1 @@ +com.example.keycloak.UserPasskeyProviderFactory \ No newline at end of file diff --git a/module-management-realm.json b/module-management-realm.json index 20e30a7b..ccd230cc 100644 --- a/module-management-realm.json +++ b/module-management-realm.json @@ -2353,7 +2353,7 @@ "cibaInterval": "5", "realmReusableOtpCode": "false" }, - "keycloakVersion": "26.0.6", + "keycloakVersion": "26.5.0", "userManagedAccessAllowed": false, "organizationsEnabled": false, "clientProfiles": {