Skip to content

Commit a548715

Browse files
Use keycloak passkey extension (#79)
Co-authored-by: Johannes Pahle <82645554+ITegs@users.noreply.github.com>
1 parent 3eaedd8 commit a548715

31 files changed

Lines changed: 1822 additions & 69 deletions

.dockerignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Slim context for docker/keycloak/Dockerfile (build context: Module-Management root)
2+
Client/node_modules
3+
Client/dist
4+
Server/build
5+
Server/.gradle
6+
**/.git
7+
.git

.example.env

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,5 @@ EMBEDDING_MODEL_NAME= sentence-transformers/all-mpnet-base-v2
4646
CHAT_MODEL_SOURCE = OpenAiChatModel
4747
EMBEDDING_MODEL_SOURCE = OpenAiEmbeddingModel
4848

49+
KC_PASSKEY_CLIENT_ID=module-management
50+
KC_ALLOWED_BROWSER_ORIGIN=https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?|https://module\.aet\.cit\.tum\.de

Client/src/app/components/header/header.component.html

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@
1313
<a class="flex gap-2 text-xl font-semibold" routerLink="/">CIT Module Management</a>
1414
</div>
1515

16-
<!-- Right Side: Theme Toggle, Sign In/Out -->
1716
<div class="flex items-center gap-4">
18-
<!-- Theme Toggle Button -->
1917
<p-button
2018
[icon]="isDarkMode() ? 'pi pi-sun' : 'pi pi-moon'"
2119
severity="contrast"
@@ -25,20 +23,6 @@
2523
[ariaLabel]="isDarkMode() ? 'Switch to Light Mode' : 'Switch to Dark Mode'"
2624
/>
2725

28-
<!-- If the user is signed in -->
29-
@if (user() !== undefined) {
30-
<p-button
31-
#userButton
32-
[label]="user()?.firstName + ' ' + user()?.lastName"
33-
severity="contrast"
34-
[outlined]="true"
35-
(onClick)="menu.toggle($event)"
36-
[style]="{ 'min-width': '150px' }"
37-
/>
38-
39-
<p-menu #menu [model]="menuItems" [popup]="true" appendTo="body" [style]="{ 'min-width': userButton.el.nativeElement.offsetWidth + 'px' }" />
40-
} @else {
41-
<p-button label="Sign In" severity="contrast" (onClick)="signIn()" />
42-
}
26+
<app-sign-in />
4327
</div>
4428
</header>

Client/src/app/components/header/header.component.ts

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,14 @@ import { SecurityStore } from '../../core/security/security-store.service';
44
import { ThemeService } from '../../core/theme/theme.service';
55
import { SidebarService } from '../side-bar/sidebar.service';
66
import { ButtonModule } from 'primeng/button';
7-
import { AvatarModule } from 'primeng/avatar';
8-
import { MenuModule } from 'primeng/menu';
97
import { TooltipModule } from 'primeng/tooltip';
10-
import { MenuItem } from 'primeng/api';
8+
import { SignInComponent } from '../sign-in/sign-in.component';
119

1210
@Component({
1311
selector: 'app-header',
1412
templateUrl: './header.component.html',
1513
standalone: true,
16-
imports: [RouterLink, ButtonModule, AvatarModule, MenuModule, TooltipModule]
14+
imports: [RouterLink, ButtonModule, TooltipModule, SignInComponent]
1715
})
1816
export class HeaderComponent {
1917
securityStore = inject(SecurityStore);
@@ -23,31 +21,7 @@ export class HeaderComponent {
2321
user = this.securityStore.user;
2422
isDarkMode = this.themeService.isDarkMode;
2523

26-
menuItems: MenuItem[] = [
27-
{
28-
label: 'Settings',
29-
icon: 'pi pi-cog',
30-
routerLink: '/account'
31-
},
32-
{
33-
separator: true
34-
},
35-
{
36-
label: 'Sign Out',
37-
icon: 'pi pi-sign-out',
38-
command: () => this.signOut()
39-
}
40-
];
41-
4224
toggleTheme() {
4325
this.themeService.toggleTheme();
4426
}
45-
46-
signIn() {
47-
this.securityStore.signIn();
48-
}
49-
50-
signOut() {
51-
this.securityStore.signOut();
52-
}
5327
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
@if (user() !== undefined) {
2+
<p-button
3+
#userButton
4+
[label]="user()?.firstName + ' ' + user()?.lastName"
5+
severity="contrast"
6+
[outlined]="true"
7+
(onClick)="menu.toggle($event)"
8+
[style]="{ 'min-width': '150px' }"
9+
/>
10+
11+
<p-menu #menu [model]="menuItems" [popup]="true" appendTo="body" [style]="{ 'min-width': userButton.el.nativeElement.offsetWidth + 'px' }" />
12+
} @else {
13+
<p-buttongroup>
14+
<p-button
15+
label="Sign in"
16+
severity="contrast"
17+
(onClick)="securityStore.signInWithTum()"
18+
pTooltip="Sign in with TUM"
19+
tooltipPosition="bottom"
20+
[disabled]="securityStore.isLoading()"
21+
/>
22+
<p-button
23+
icon="pi pi-key"
24+
severity="contrast"
25+
[ariaLabel]="'Sign in with passkey'"
26+
pTooltip="Sign in with passkey"
27+
tooltipPosition="bottom"
28+
[disabled]="securityStore.isLoading()"
29+
[loading]="securityStore.isLoading()"
30+
(onClick)="securityStore.signInWithPasskey()"
31+
/>
32+
</p-buttongroup>
33+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Component, inject } from '@angular/core';
2+
import { RouterModule } from '@angular/router';
3+
import { ButtonModule } from 'primeng/button';
4+
import { ButtonGroupModule } from 'primeng/buttongroup';
5+
import { MenuModule } from 'primeng/menu';
6+
import { TooltipModule } from 'primeng/tooltip';
7+
import { MenuItem } from 'primeng/api';
8+
import { SecurityStore } from '../../core/security/security-store.service';
9+
10+
@Component({
11+
selector: 'app-sign-in',
12+
standalone: true,
13+
imports: [RouterModule, ButtonModule, ButtonGroupModule, MenuModule, TooltipModule],
14+
templateUrl: './sign-in.component.html'
15+
})
16+
export class SignInComponent {
17+
readonly securityStore = inject(SecurityStore);
18+
readonly user = this.securityStore.user;
19+
20+
menuItems: MenuItem[] = [
21+
{
22+
label: 'Settings',
23+
icon: 'pi pi-cog',
24+
routerLink: '/account'
25+
},
26+
{
27+
separator: true
28+
},
29+
{
30+
label: 'Sign Out',
31+
icon: 'pi pi-sign-out',
32+
command: () => this.securityStore.signOut()
33+
}
34+
];
35+
}

Client/src/app/core/security/keycloak.service.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,14 @@ export class KeycloakService {
5454
}
5555
}
5656

57-
login(returnUrl?: string) {
58-
return this.keycloak.login({ redirectUri: window.location.origin + (returnUrl || ''), action: 'webauthn-register-passwordless:skip_if_exists' });
57+
loginWithTumRedirect(returnUrl?: string) {
58+
return this.keycloak.login({ redirectUri: window.location.origin + (returnUrl ?? '') });
5959
}
6060

6161
logout() {
6262
return this.keycloak.logout({ redirectUri: environment.redirect });
6363
}
6464

65-
registerPasskey(returnUrl?: string) {
66-
return this.keycloak.login({ redirectUri: window.location.origin + (returnUrl || ''), action: 'webauthn-register-passwordless' });
67-
}
68-
6965
getCredentials() {
7066
const url = `${environment.keycloak.url}/realms/${environment.keycloak.realm}/account/credentials`;
7167
return this.http.get<KeycloakCredentialType[]>(url);
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { inject, Injectable } from '@angular/core';
2+
import { environment } from '../../../../environments/environment';
3+
import { KeycloakService } from './keycloak.service';
4+
5+
/**
6+
* In-app WebAuthn via Keycloak realm extension (same flow as ba-test-keycloak {@code public/app.js}):
7+
* register: {@code /passkey/challenge} + {@code /passkey/save}; sign-in: {@code /passkey/get-credential-id} + {@code /passkey/authenticate}.
8+
*/
9+
@Injectable({ providedIn: 'root' })
10+
export class PasskeyExtensionService {
11+
private readonly keycloakService = inject(KeycloakService);
12+
13+
private passkeyBaseUrl(): string {
14+
const base = environment.keycloak.url.replace(/\/$/, '');
15+
const realm = encodeURIComponent(environment.keycloak.realm);
16+
return `${base}/realms/${realm}/passkey`;
17+
}
18+
19+
private getUrl(path: string): string {
20+
const p = path.replace(/^\/+/, '');
21+
return `${this.passkeyBaseUrl()}/${p}`;
22+
}
23+
24+
private base64UrlToUint8Array(value: string): Uint8Array {
25+
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
26+
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
27+
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
28+
}
29+
30+
private bufferToBase64Url(buffer: ArrayBuffer): string {
31+
const bytes = new Uint8Array(buffer);
32+
let binary = '';
33+
for (let i = 0; i < bytes.length; i += 1) {
34+
binary += String.fromCharCode(bytes[i]);
35+
}
36+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
37+
}
38+
39+
private async readJsonBody<T>(response: Response): Promise<T | undefined> {
40+
const contentType = response.headers.get('content-type') ?? '';
41+
if (!contentType.toLowerCase().includes('application/json')) {
42+
return undefined;
43+
}
44+
try {
45+
return (await response.json()) as T;
46+
} catch {
47+
return undefined;
48+
}
49+
}
50+
51+
/**
52+
* Register a new passkey for the current user (must already be logged in).
53+
*/
54+
async registerPasskeyInBrowser(): Promise<void> {
55+
const kc = this.keycloakService.keycloak;
56+
if (!kc.authenticated || !kc.token) {
57+
throw new Error('You must be signed in to register a passkey.');
58+
}
59+
60+
await kc.updateToken(60);
61+
const token = kc.token;
62+
if (!token) {
63+
throw new Error('No access token available.');
64+
}
65+
66+
const parsed = kc.tokenParsed as Record<string, unknown> | undefined;
67+
const accountId = String(parsed?.['sub'] ?? parsed?.['preferred_username'] ?? '');
68+
const accountName = String(parsed?.['preferred_username'] ?? parsed?.['email'] ?? '');
69+
const displayName = String(parsed?.['name'] ?? ([parsed?.['given_name'], parsed?.['family_name']].filter(Boolean).join(' ') || accountName || 'User'));
70+
71+
if (!accountId || !accountName) {
72+
throw new Error('Missing user identity in token for passkey registration.');
73+
}
74+
75+
const challengeRes = await fetch(this.getUrl('challenge'), { credentials: 'include' });
76+
if (!challengeRes.ok) {
77+
throw new Error(`Failed to get WebAuthn challenge (${challengeRes.status})`);
78+
}
79+
const { challenge } = (await challengeRes.json()) as { challenge: string };
80+
if (!challenge) {
81+
throw new Error('Invalid challenge response from Keycloak');
82+
}
83+
84+
const userIdBytes = new TextEncoder().encode(accountId).slice(0, 64);
85+
86+
const credential = (await navigator.credentials.create({
87+
publicKey: {
88+
challenge: this.base64UrlToUint8Array(challenge) as BufferSource,
89+
rp: { name: 'Module Management', id: window.location.hostname },
90+
user: { id: userIdBytes, name: accountName, displayName },
91+
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
92+
authenticatorSelection: { userVerification: 'preferred', residentKey: 'required' },
93+
attestation: 'none'
94+
}
95+
})) as PublicKeyCredential | null;
96+
97+
if (!credential?.response) {
98+
throw new Error('Passkey creation was cancelled or failed.');
99+
}
100+
101+
const response = credential.response as AuthenticatorAttestationResponse;
102+
const savePayload = {
103+
credentialId: this.bufferToBase64Url(credential.rawId),
104+
rawId: this.bufferToBase64Url(credential.rawId),
105+
clientDataJSON: this.bufferToBase64Url(response.clientDataJSON),
106+
attestationObject: this.bufferToBase64Url(response.attestationObject),
107+
challenge
108+
};
109+
110+
const saveRes = await fetch(this.getUrl('save'), {
111+
method: 'POST',
112+
credentials: 'include',
113+
headers: {
114+
'Content-Type': 'application/json',
115+
Authorization: `Bearer ${token}`
116+
},
117+
body: JSON.stringify(savePayload)
118+
});
119+
120+
const saveText = await saveRes.text();
121+
if (!saveRes.ok) {
122+
throw new Error(saveText || `Failed to store passkey (${saveRes.status})`);
123+
}
124+
125+
await kc.updateToken(-1);
126+
}
127+
128+
/**
129+
* Sign in with passkey only (no Keycloak UI redirect).
130+
* The extension endpoint sets the Keycloak login cookie; the SPA should then reload
131+
* and let keycloak-js initialize via check-sso.
132+
*/
133+
async signInWithPasskey(): Promise<void> {
134+
const optionsResponse = await fetch(this.getUrl('challenge'), { credentials: 'include' });
135+
const res = await this.readJsonBody<{ challenge?: string; credentialId?: string; error?: string }>(optionsResponse);
136+
if (!optionsResponse.ok) {
137+
throw new Error(res?.error || `Failed to get passkey options (${optionsResponse.status})`);
138+
}
139+
if (!res?.challenge) {
140+
throw new Error('Invalid challenge response from server');
141+
}
142+
143+
const publicKey: PublicKeyCredentialRequestOptions = {
144+
challenge: this.base64UrlToUint8Array(res.challenge) as BufferSource,
145+
userVerification: 'preferred'
146+
};
147+
if (res.credentialId) {
148+
publicKey.allowCredentials = [{ type: 'public-key', id: this.base64UrlToUint8Array(res.credentialId) as BufferSource }];
149+
}
150+
151+
const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential | null;
152+
if (!credential?.response) {
153+
throw new Error('Passkey sign-in was cancelled or failed.');
154+
}
155+
156+
const ar = credential.response as AuthenticatorAssertionResponse;
157+
const payload = {
158+
credentialId: this.bufferToBase64Url(credential.rawId),
159+
rawId: this.bufferToBase64Url(credential.rawId),
160+
clientDataJSON: this.bufferToBase64Url(ar.clientDataJSON),
161+
authenticatorData: this.bufferToBase64Url(ar.authenticatorData),
162+
signature: this.bufferToBase64Url(ar.signature),
163+
challenge: res.challenge
164+
};
165+
166+
const authRes = await fetch(this.getUrl('authenticate'), {
167+
method: 'POST',
168+
credentials: 'include',
169+
redirect: 'manual',
170+
headers: { 'Content-Type': 'application/json' },
171+
body: JSON.stringify(payload)
172+
});
173+
174+
if (authRes.type === 'opaqueredirect') {
175+
return;
176+
}
177+
178+
const authResult = await this.readJsonBody<{ error?: string }>(authRes);
179+
if (!authRes.ok) {
180+
throw new Error(authResult?.error || `Passkey authentication failed (${authRes.status})`);
181+
}
182+
}
183+
}

0 commit comments

Comments
 (0)