Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 1 addition & 17 deletions Client/src/app/components/header/header.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@
<a class="flex gap-2 text-xl font-semibold" routerLink="/">CIT Module Management</a>
</div>

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

<!-- If the user is signed in -->
@if (user() !== undefined) {
<p-button
#userButton
[label]="user()?.firstName + ' ' + user()?.lastName"
severity="contrast"
[outlined]="true"
(onClick)="menu.toggle($event)"
[style]="{ 'min-width': '150px' }"
/>

<p-menu #menu [model]="menuItems" [popup]="true" appendTo="body" [style]="{ 'min-width': userButton.el.nativeElement.offsetWidth + 'px' }" />
} @else {
<p-button label="Sign In" severity="contrast" (onClick)="signIn()" />
}
<app-sign-in />
</div>
</header>
30 changes: 2 additions & 28 deletions Client/src/app/components/header/header.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
}
}
33 changes: 33 additions & 0 deletions Client/src/app/components/sign-in/sign-in.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
@if (user() !== undefined) {
<p-button
#userButton
[label]="user()?.firstName + ' ' + user()?.lastName"
severity="contrast"
[outlined]="true"
(onClick)="menu.toggle($event)"
[style]="{ 'min-width': '150px' }"
/>

<p-menu #menu [model]="menuItems" [popup]="true" appendTo="body" [style]="{ 'min-width': userButton.el.nativeElement.offsetWidth + 'px' }" />
} @else {
<p-buttongroup>
<p-button
label="Sign in"
severity="contrast"
(onClick)="securityStore.signInWithTum()"
pTooltip="Sign in with TUM"
tooltipPosition="bottom"
[disabled]="securityStore.isLoading()"
/>
<p-button
icon="pi pi-key"
severity="contrast"
[ariaLabel]="'Sign in with passkey'"
pTooltip="Sign in with passkey"
tooltipPosition="bottom"
[disabled]="securityStore.isLoading()"
[loading]="securityStore.isLoading()"
(onClick)="securityStore.signInWithPasskey()"
/>
</p-buttongroup>
}
35 changes: 35 additions & 0 deletions Client/src/app/components/sign-in/sign-in.component.ts
Original file line number Diff line number Diff line change
@@ -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()
}
];
}
8 changes: 2 additions & 6 deletions Client/src/app/core/security/keycloak.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KeycloakCredentialType[]>(url);
Expand Down
183 changes: 183 additions & 0 deletions Client/src/app/core/security/passkey-extension.service.ts
Original file line number Diff line number Diff line change
@@ -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<T>(response: Response): Promise<T | undefined> {
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<void> {
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<string, unknown> | 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<void> {
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})`);
}
}
}
Loading
Loading