Add passkey (WebAuthn) authentication with self-registration
Enable users to sign up and sign in using passkeys (biometrics/security keys) without needing a manually-created Authentik account. The existing SSO login remains as an alternative. Backend: - Add WebAuthn registration/authentication endpoints via py-webauthn - Issue HS256 JWTs for passkey users, with Redis-backed challenge storage - Dual JWT verification in auth middleware (issuer-based routing: passkey HS256 vs Authentik RS256) - PasskeyCredential model + migration making user.password nullable - UserRepository with full CRUD for users and credentials Frontend: - AuthUser type abstraction unifying OIDC and passkey users - Passkey service using @simplewebauthn/browser for WebAuthn ceremonies - LoginModal redesigned with Sign In / Sign Up tabs - Type migration from oidc-client-ts User to AuthUser across all services and components
This commit is contained in:
parent
95c0ddc4c6
commit
a8b7eace48
26 changed files with 1229 additions and 129 deletions
155
crawler/frontend/src/auth/passkeyService.ts
Normal file
155
crawler/frontend/src/auth/passkeyService.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
|
||||
import type { AuthUser } from './types';
|
||||
|
||||
const PASSKEY_USER_KEY = 'passkey_user';
|
||||
|
||||
interface RegisterBeginResponse {
|
||||
options: PublicKeyCredentialCreationOptionsJSON;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
interface LoginBeginResponse {
|
||||
options: PublicKeyCredentialRequestOptionsJSON;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
interface AuthTokenResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
// WebAuthn JSON types from the spec
|
||||
type PublicKeyCredentialCreationOptionsJSON = Parameters<typeof startRegistration>[0];
|
||||
type PublicKeyCredentialRequestOptionsJSON = Parameters<typeof startAuthentication>[0];
|
||||
|
||||
function parseJwt(token: string): Record<string, unknown> {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
);
|
||||
return JSON.parse(jsonPayload);
|
||||
}
|
||||
|
||||
export async function registerPasskey(email: string): Promise<AuthUser> {
|
||||
// Step 1: Begin registration
|
||||
const beginRes = await fetch('/api/passkey/register/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!beginRes.ok) {
|
||||
const err = await beginRes.json();
|
||||
throw new Error(err.detail || 'Failed to start registration');
|
||||
}
|
||||
|
||||
const beginData: RegisterBeginResponse = await beginRes.json();
|
||||
|
||||
// Step 2: Browser WebAuthn ceremony
|
||||
const attResp = await startRegistration(beginData.options);
|
||||
|
||||
// Step 3: Complete registration
|
||||
const completeRes = await fetch('/api/passkey/register/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: beginData.session_id,
|
||||
credential: attResp,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!completeRes.ok) {
|
||||
const err = await completeRes.json();
|
||||
throw new Error(err.detail || 'Failed to complete registration');
|
||||
}
|
||||
|
||||
const { token }: AuthTokenResponse = await completeRes.json();
|
||||
const claims = parseJwt(token);
|
||||
|
||||
const user: AuthUser = {
|
||||
sub: claims.sub as string,
|
||||
email: claims.email as string,
|
||||
name: (claims.name as string) || (claims.email as string),
|
||||
accessToken: token,
|
||||
provider: 'passkey',
|
||||
};
|
||||
|
||||
localStorage.setItem(PASSKEY_USER_KEY, JSON.stringify(user));
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function loginWithPasskey(): Promise<AuthUser> {
|
||||
// Step 1: Begin authentication
|
||||
const beginRes = await fetch('/api/passkey/login/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (!beginRes.ok) {
|
||||
const err = await beginRes.json();
|
||||
throw new Error(err.detail || 'Failed to start login');
|
||||
}
|
||||
|
||||
const beginData: LoginBeginResponse = await beginRes.json();
|
||||
|
||||
// Step 2: Browser WebAuthn ceremony
|
||||
const assertionResp = await startAuthentication(beginData.options);
|
||||
|
||||
// Step 3: Complete authentication
|
||||
const completeRes = await fetch('/api/passkey/login/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: beginData.session_id,
|
||||
credential: assertionResp,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!completeRes.ok) {
|
||||
const err = await completeRes.json();
|
||||
throw new Error(err.detail || 'Failed to complete login');
|
||||
}
|
||||
|
||||
const { token }: AuthTokenResponse = await completeRes.json();
|
||||
const claims = parseJwt(token);
|
||||
|
||||
const user: AuthUser = {
|
||||
sub: claims.sub as string,
|
||||
email: claims.email as string,
|
||||
name: (claims.name as string) || (claims.email as string),
|
||||
accessToken: token,
|
||||
provider: 'passkey',
|
||||
};
|
||||
|
||||
localStorage.setItem(PASSKEY_USER_KEY, JSON.stringify(user));
|
||||
return user;
|
||||
}
|
||||
|
||||
export function getStoredPasskeyUser(): AuthUser | null {
|
||||
const stored = localStorage.getItem(PASSKEY_USER_KEY);
|
||||
if (!stored) return null;
|
||||
|
||||
try {
|
||||
const user: AuthUser = JSON.parse(stored);
|
||||
|
||||
// Check JWT expiration
|
||||
const claims = parseJwt(user.accessToken);
|
||||
const exp = claims.exp as number | undefined;
|
||||
if (exp && exp * 1000 < Date.now()) {
|
||||
localStorage.removeItem(PASSKEY_USER_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch {
|
||||
localStorage.removeItem(PASSKEY_USER_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPasskeyUser(): void {
|
||||
localStorage.removeItem(PASSKEY_USER_KEY);
|
||||
}
|
||||
19
crawler/frontend/src/auth/types.ts
Normal file
19
crawler/frontend/src/auth/types.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { User } from 'oidc-client-ts';
|
||||
|
||||
export interface AuthUser {
|
||||
sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
accessToken: string;
|
||||
provider: 'oidc' | 'passkey';
|
||||
}
|
||||
|
||||
export function fromOidcUser(user: User): AuthUser {
|
||||
return {
|
||||
sub: user.profile.sub,
|
||||
email: user.profile.email ?? '',
|
||||
name: user.profile.name ?? user.profile.email ?? '',
|
||||
accessToken: user.access_token,
|
||||
provider: 'oidc',
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue