Skip to content

Commit 1def6e5

Browse files
mohamedalaaserITegs
andcommitted
Use keycloak passkey extension (#79)
Co-authored-by: Johannes Pahle <82645554+ITegs@users.noreply.github.com>
1 parent 92ff611 commit 1def6e5

15 files changed

Lines changed: 1171 additions & 548 deletions

.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/core/security/keycloak.service.ts

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -58,42 +58,6 @@ export class KeycloakService {
5858
return this.keycloak.login({ redirectUri: window.location.origin + (returnUrl ?? '') });
5959
}
6060

61-
applyPasskeyTokens(accessToken: string, refreshToken: string): void {
62-
const kc = this.keycloak;
63-
kc.token = accessToken;
64-
kc.refreshToken = refreshToken;
65-
kc.idToken = undefined;
66-
kc.idTokenParsed = undefined;
67-
68-
const parsed = this.parseJwtPayload(accessToken);
69-
kc.tokenParsed = parsed;
70-
kc.refreshTokenParsed = this.parseJwtPayload(refreshToken);
71-
72-
if (parsed) {
73-
kc.subject = parsed.sub;
74-
const ext = parsed as KeycloakTokenParsed & { sid?: string; session_state?: string };
75-
kc.sessionId = ext.sid ?? ext.session_state;
76-
kc.realmAccess = parsed.realm_access;
77-
kc.resourceAccess = parsed.resource_access;
78-
}
79-
kc.timeSkew = 0;
80-
kc.authenticated = true;
81-
}
82-
83-
private parseJwtPayload(token: string): KeycloakTokenParsed | undefined {
84-
try {
85-
const parts = token.split('.');
86-
if (parts.length < 2) {
87-
return undefined;
88-
}
89-
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
90-
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
91-
return JSON.parse(atob(padded)) as KeycloakTokenParsed;
92-
} catch {
93-
return undefined;
94-
}
95-
}
96-
9761
logout() {
9862
return this.keycloak.logout({ redirectUri: environment.redirect });
9963
}

Client/src/app/core/security/passkey-extension.service.ts

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ export class PasskeyExtensionService {
3636
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
3737
}
3838

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+
3951
/**
4052
* Register a new passkey for the current user (must already be logged in).
4153
*/
@@ -54,16 +66,13 @@ export class PasskeyExtensionService {
5466
const parsed = kc.tokenParsed as Record<string, unknown> | undefined;
5567
const accountId = String(parsed?.['sub'] ?? parsed?.['preferred_username'] ?? '');
5668
const accountName = String(parsed?.['preferred_username'] ?? parsed?.['email'] ?? '');
57-
const displayName = String(
58-
parsed?.['name'] ??
59-
([parsed?.['given_name'], parsed?.['family_name']].filter(Boolean).join(' ') || accountName || 'User')
60-
);
69+
const displayName = String(parsed?.['name'] ?? ([parsed?.['given_name'], parsed?.['family_name']].filter(Boolean).join(' ') || accountName || 'User'));
6170

6271
if (!accountId || !accountName) {
6372
throw new Error('Missing user identity in token for passkey registration.');
6473
}
6574

66-
const challengeRes = await fetch(this.getUrl('challenge'));
75+
const challengeRes = await fetch(this.getUrl('challenge'), { credentials: 'include' });
6776
if (!challengeRes.ok) {
6877
throw new Error(`Failed to get WebAuthn challenge (${challengeRes.status})`);
6978
}
@@ -94,11 +103,13 @@ export class PasskeyExtensionService {
94103
credentialId: this.bufferToBase64Url(credential.rawId),
95104
rawId: this.bufferToBase64Url(credential.rawId),
96105
clientDataJSON: this.bufferToBase64Url(response.clientDataJSON),
97-
attestationObject: this.bufferToBase64Url(response.attestationObject)
106+
attestationObject: this.bufferToBase64Url(response.attestationObject),
107+
challenge
98108
};
99109

100110
const saveRes = await fetch(this.getUrl('save'), {
101111
method: 'POST',
112+
credentials: 'include',
102113
headers: {
103114
'Content-Type': 'application/json',
104115
Authorization: `Bearer ${token}`
@@ -115,15 +126,17 @@ export class PasskeyExtensionService {
115126
}
116127

117128
/**
118-
* Sign in with passkey only (no Keycloak UI redirect). Returns tokens from {@code POST /passkey/authenticate}.
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.
119132
*/
120-
async signInWithPasskey(): Promise<{ access_token: string; refresh_token: string }> {
121-
const optionsResponse = await fetch(this.getUrl('get-credential-id'));
122-
const res = (await optionsResponse.json()) as { challenge?: string; credentialId?: string; error?: string };
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);
123136
if (!optionsResponse.ok) {
124137
throw new Error(res?.error || `Failed to get passkey options (${optionsResponse.status})`);
125138
}
126-
if (!res.challenge) {
139+
if (!res?.challenge) {
127140
throw new Error('Invalid challenge response from server');
128141
}
129142

@@ -132,9 +145,7 @@ export class PasskeyExtensionService {
132145
userVerification: 'preferred'
133146
};
134147
if (res.credentialId) {
135-
publicKey.allowCredentials = [
136-
{ type: 'public-key', id: this.base64UrlToUint8Array(res.credentialId) as BufferSource }
137-
];
148+
publicKey.allowCredentials = [{ type: 'public-key', id: this.base64UrlToUint8Array(res.credentialId) as BufferSource }];
138149
}
139150

140151
const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential | null;
@@ -154,17 +165,19 @@ export class PasskeyExtensionService {
154165

155166
const authRes = await fetch(this.getUrl('authenticate'), {
156167
method: 'POST',
168+
credentials: 'include',
169+
redirect: 'manual',
157170
headers: { 'Content-Type': 'application/json' },
158171
body: JSON.stringify(payload)
159172
});
160173

161-
const authResult = (await authRes.json()) as { access_token?: string; refresh_token?: string; error?: string };
174+
if (authRes.type === 'opaqueredirect') {
175+
return;
176+
}
177+
178+
const authResult = await this.readJsonBody<{ error?: string }>(authRes);
162179
if (!authRes.ok) {
163180
throw new Error(authResult?.error || `Passkey authentication failed (${authRes.status})`);
164181
}
165-
if (!authResult.access_token || !authResult.refresh_token) {
166-
throw new Error('Invalid token response from server');
167-
}
168-
return { access_token: authResult.access_token, refresh_token: authResult.refresh_token };
169182
}
170183
}

Client/src/app/core/security/security-store.service.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,10 @@ export class SecurityStore {
9090
async signInWithPasskey(): Promise<void> {
9191
this.isLoading.set(true);
9292
try {
93-
const tokens = await this.passkeyExtension.signInWithPasskey();
94-
this.keycloakService.applyPasskeyTokens(tokens.access_token, tokens.refresh_token);
95-
await this.loadPasskeys();
96-
const user = await firstValueFrom(this.userControllerService.getCurrentUser());
97-
this.user.set(user);
93+
await this.passkeyExtension.signInWithPasskey();
94+
// Passkey authenticate sets the Keycloak login cookie; reload so init(check-sso)
95+
// can bootstrap keycloak-js token state through the standard adapter flow.
96+
window.location.reload();
9897
} catch (error) {
9998
this.messageService.add({ severity: 'error', summary: 'Sign-in', detail: 'Something went wrong' });
10099
console.error(error);

docker/docker-compose.dev.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,11 @@ services:
2424
environment:
2525
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin}
2626
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
27+
KC_PASSKEY_CLIENT_ID: ${KC_PASSKEY_CLIENT_ID:-module-management}
28+
KC_ALLOWED_BROWSER_ORIGIN: ${KC_ALLOWED_BROWSER_ORIGIN:-http://localhost:4200}
2729
command: start-dev --import-realm
2830
volumes:
31+
- keycloak_data:/opt/keycloak/data
2932
- ../module-management-realm.json:/opt/keycloak/data/import/module-management-realm.json
3033
- ../keycloak-themes:/opt/keycloak/themes
3134
ports:
@@ -69,6 +72,7 @@ services:
6972

7073
volumes:
7174
postgres_data:
75+
keycloak_data:
7276
hf_cache:
7377

7478

docker/docker-compose.staging.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ services:
3838
environment:
3939
- KC_BOOTSTRAP_ADMIN_USERNAME=${KEYCLOAK_ADMIN_USERNAME}
4040
- KC_BOOTSTRAP_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD}
41+
- KC_PASSKEY_CLIENT_ID=${KC_PASSKEY_CLIENT_ID:-module-management}
42+
- KC_ALLOWED_BROWSER_ORIGIN=${KC_ALLOWED_BROWSER_ORIGIN:-http://localhost:4200}
4143
- KC_DB=postgres
4244
- KC_DB_URL_HOST=postgres
4345
- KC_DB_URL_DATABASE=keycloak

keycloak-extension-passkey/pom.xml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,21 @@
1212

1313
<properties>
1414
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
15-
<keycloak.version>26.1.3</keycloak.version>
15+
<keycloak.version>26.5.0</keycloak.version>
1616
</properties>
1717

1818
<dependencies>
1919
<dependency>
2020
<groupId>org.keycloak</groupId>
2121
<artifactId>keycloak-server-spi</artifactId>
2222
<version>${keycloak.version}</version>
23+
<scope>provided</scope>
2324
</dependency>
2425
<dependency>
2526
<groupId>org.keycloak</groupId>
2627
<artifactId>keycloak-services</artifactId>
2728
<version>${keycloak.version}</version>
29+
<scope>provided</scope>
2830
</dependency>
2931
<dependency>
3032
<groupId>org.keycloak</groupId>
@@ -36,6 +38,7 @@
3638
<groupId>org.jboss.resteasy</groupId>
3739
<artifactId>resteasy-core-spi</artifactId>
3840
<version>6.2.11.Final</version>
41+
<scope>provided</scope>
3942
</dependency>
4043
<dependency>
4144
<groupId>org.projectlombok</groupId>
@@ -51,17 +54,17 @@
5154
<dependency>
5255
<groupId>com.fasterxml.jackson.core</groupId>
5356
<artifactId>jackson-databind</artifactId>
54-
<version>2.13.4.2</version>
57+
<version>2.18.3</version>
5558
</dependency>
5659
</dependencies>
5760
<build>
5861
<plugins>
5962
<plugin>
6063
<groupId>org.apache.maven.plugins</groupId>
6164
<artifactId>maven-compiler-plugin</artifactId>
65+
<version>3.13.0</version>
6266
<configuration>
63-
<source>16</source>
64-
<target>16</target>
67+
<release>17</release>
6568
<annotationProcessorPaths>
6669
<path>
6770
<groupId>org.projectlombok</groupId>

0 commit comments

Comments
 (0)