feat: fully backend-driven OIDC session flow (session cookie, not bearer token)
Replace the hybrid flow (frontend PKCE + POST /auth/session token exchange, access token in sessionStorage) with a classic backend-driven BFF: the browser only ever navigates to GET /api/v1/auth/login and is redirected straight to the IdP; PKCE verifier/state live server-side in Redis (SessionStoreService); GET /api/v1/auth/callback (now the registered IdP redirect URI, replacing the frontend's /auth/callback route, which is deleted) verifies the id_token, JIT-provisions the user, creates a Redis-backed session, and sets one httpOnly SameSite=Lax cookie before redirecting into the app. No token material of any kind ever reaches the browser. OidcAuthGuard (per-request bearer JWT verification) is replaced by SessionAuthGuard (cookie -> Redis session lookup) across every controller that used it. cookie-parser is now wired into main.ts. Frontend AuthService shrinks to login()/logout()/ensureSessionChecked(); pkce.ts, auth.interceptor.ts, and the callback component/route are all removed as dead code under this model. New required env var: APP_BASE_URL (source of truth for the OIDC redirect_uri and the post-login redirect target). Verified end-to-end against the real API, Redis, and a mocked IdP: login redirect shape, callback cookie + redirect, state-replay rejection, /users/me 401<->200 around the cookie, and logout.
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm"
|
||||
"packageManager": "npm",
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
import { authInterceptor } from './auth/auth.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
provideHttpClient(),
|
||||
provideServiceWorker('ngsw-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
registrationStrategy: 'registerWhenStable:30000',
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
<p>Reisen planen, gemeinsam entscheiden.</p>
|
||||
<nav>
|
||||
<a routerLink="/trips">Reisen</a>
|
||||
@if (authService.isAuthenticated()) {
|
||||
<button type="button" (click)="logout()">Abmelden</button>
|
||||
}
|
||||
</nav>
|
||||
<router-outlet />
|
||||
</main>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { Callback } from './auth/callback/callback';
|
||||
import { authGuard } from './auth/auth.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: 'auth/callback', component: Callback },
|
||||
{
|
||||
path: 'trips',
|
||||
canActivate: [authGuard],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterLink, RouterOutlet } from '@angular/router';
|
||||
import { AuthService } from './auth/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -7,4 +8,10 @@ import { RouterLink, RouterOutlet } from '@angular/router';
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
})
|
||||
export class App {}
|
||||
export class App {
|
||||
protected readonly authService = inject(AuthService);
|
||||
|
||||
logout(): void {
|
||||
void this.authService.logout();
|
||||
}
|
||||
}
|
||||
|
||||
26
frontend/src/app/auth/auth.guard.spec.ts
Normal file
26
frontend/src/app/auth/auth.guard.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { authGuard } from './auth.guard';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('authGuard', () => {
|
||||
it('allows activation when a session already exists', async () => {
|
||||
const authService = { ensureSessionChecked: vi.fn().mockResolvedValue(true), login: vi.fn() };
|
||||
TestBed.configureTestingModule({ providers: [{ provide: AuthService, useValue: authService }] });
|
||||
|
||||
const result = await TestBed.runInInjectionContext(() => authGuard({} as never, {} as never));
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(authService.login).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggers login and denies activation when no session exists', async () => {
|
||||
const authService = { ensureSessionChecked: vi.fn().mockResolvedValue(false), login: vi.fn() };
|
||||
TestBed.configureTestingModule({ providers: [{ provide: AuthService, useValue: authService }] });
|
||||
|
||||
const result = await TestBed.runInInjectionContext(() => authGuard({} as never, {} as never));
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(authService.login).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,12 @@ import { inject } from '@angular/core';
|
||||
import { CanActivateFn } from '@angular/router';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
export const authGuard: CanActivateFn = async () => {
|
||||
const authService = inject(AuthService);
|
||||
if (authService.isAuthenticated()) {
|
||||
const authenticated = await authService.ensureSessionChecked();
|
||||
if (authenticated) {
|
||||
return true;
|
||||
}
|
||||
void authService.login();
|
||||
authService.login();
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { HttpHandlerFn, HttpRequest, HttpResponse } from '@angular/common/http';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { firstValueFrom, of } from 'rxjs';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { authInterceptor } from './auth.interceptor';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('authInterceptor', () => {
|
||||
it('attaches a bearer token to API requests', async () => {
|
||||
const authService = { getAccessToken: vi.fn().mockResolvedValue('token-123') };
|
||||
TestBed.configureTestingModule({ providers: [{ provide: AuthService, useValue: authService }] });
|
||||
|
||||
const req = new HttpRequest('GET', '/api/v1/trips');
|
||||
const next = vi.fn().mockReturnValue(of(new HttpResponse())) as unknown as HttpHandlerFn;
|
||||
|
||||
await firstValueFrom(TestBed.runInInjectionContext(() => authInterceptor(req, next)));
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
const forwarded = vi.mocked(next).mock.calls[0][0] as HttpRequest<unknown>;
|
||||
expect(forwarded.headers.get('Authorization')).toBe('Bearer token-123');
|
||||
});
|
||||
|
||||
it('does not attach a token to non-API requests', async () => {
|
||||
const authService = { getAccessToken: vi.fn().mockResolvedValue('token-123') };
|
||||
TestBed.configureTestingModule({ providers: [{ provide: AuthService, useValue: authService }] });
|
||||
|
||||
const req = new HttpRequest('GET', 'https://example.com/unrelated');
|
||||
const next = vi.fn().mockReturnValue(of(new HttpResponse())) as unknown as HttpHandlerFn;
|
||||
|
||||
await firstValueFrom(TestBed.runInInjectionContext(() => authInterceptor(req, next)));
|
||||
|
||||
expect(authService.getAccessToken).not.toHaveBeenCalled();
|
||||
const forwarded = vi.mocked(next).mock.calls[0][0] as HttpRequest<unknown>;
|
||||
expect(forwarded.headers.get('Authorization')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { HttpHandlerFn, HttpRequest } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { from, switchMap } from 'rxjs';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
export function authInterceptor(req: HttpRequest<unknown>, next: HttpHandlerFn) {
|
||||
if (!req.url.startsWith(environment.apiBaseUrl)) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const authService = inject(AuthService);
|
||||
return from(authService.getAccessToken()).pipe(
|
||||
switchMap((token) => {
|
||||
const authorizedReq = token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req;
|
||||
return next(authorizedReq);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,87 +1,62 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('AuthService', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('starts unauthenticated when no session is stored', () => {
|
||||
it('starts with an unknown authentication state until checked', () => {
|
||||
const service = TestBed.inject(AuthService);
|
||||
expect(service.isAuthenticated()).toBe(false);
|
||||
expect(service.isAuthenticated()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('starts authenticated when a non-expired access token is already stored', () => {
|
||||
sessionStorage.setItem('auth.accessToken', 'stored-token');
|
||||
sessionStorage.setItem('auth.expiresAt', String(Date.now() + 60_000));
|
||||
const service = TestBed.inject(AuthService);
|
||||
expect(service.isAuthenticated()).toBe(true);
|
||||
});
|
||||
|
||||
it('login() persists a PKCE verifier and state before redirecting', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
json: () => Promise.resolve({ authorization_endpoint: 'https://idp.example.test/oidc/auth' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
it('login() navigates to the backend login endpoint', () => {
|
||||
vi.stubGlobal('location', { ...window.location, href: '' });
|
||||
|
||||
const service = TestBed.inject(AuthService);
|
||||
await service.login();
|
||||
service.login();
|
||||
|
||||
expect(sessionStorage.getItem('auth.codeVerifier')).toBeTruthy();
|
||||
expect(sessionStorage.getItem('auth.state')).toBeTruthy();
|
||||
expect(window.location.href).toContain('https://idp.example.test/oidc/auth?');
|
||||
expect(window.location.href).toContain('code_challenge_method=S256');
|
||||
expect(window.location.href).toBe('/api/v1/auth/login');
|
||||
});
|
||||
|
||||
it('completeLogin() rejects a state that does not match the one stored before redirecting', async () => {
|
||||
sessionStorage.setItem('auth.codeVerifier', 'verifier-1');
|
||||
sessionStorage.setItem('auth.state', 'expected-state');
|
||||
vi.stubGlobal('location', { ...window.location, search: '?code=abc&state=wrong-state' });
|
||||
|
||||
const service = TestBed.inject(AuthService);
|
||||
await expect(service.completeLogin()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('completeLogin() exchanges the code via the backend and stores the resulting access token', async () => {
|
||||
sessionStorage.setItem('auth.codeVerifier', 'verifier-1');
|
||||
sessionStorage.setItem('auth.state', 'state-1');
|
||||
vi.stubGlobal('location', { ...window.location, search: '?code=abc&state=state-1' });
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ accessToken: 'at-1', expiresIn: 3600 }),
|
||||
});
|
||||
it('ensureSessionChecked() reports authenticated when /users/me succeeds, and only fetches once', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const service = TestBed.inject(AuthService);
|
||||
await service.completeLogin();
|
||||
await expect(service.ensureSessionChecked()).resolves.toBe(true);
|
||||
await expect(service.ensureSessionChecked()).resolves.toBe(true);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/users/me');
|
||||
expect(service.isAuthenticated()).toBe(true);
|
||||
await expect(service.getAccessToken()).resolves.toBe('at-1');
|
||||
expect(sessionStorage.getItem('auth.codeVerifier')).toBeNull();
|
||||
});
|
||||
|
||||
it('logout() clears the stored session', () => {
|
||||
sessionStorage.setItem('auth.accessToken', 'at-1');
|
||||
sessionStorage.setItem('auth.expiresAt', String(Date.now() + 60_000));
|
||||
it('ensureSessionChecked() reports unauthenticated when /users/me returns 401', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||||
|
||||
const service = TestBed.inject(AuthService);
|
||||
|
||||
service.logout();
|
||||
|
||||
await expect(service.ensureSessionChecked()).resolves.toBe(false);
|
||||
expect(service.isAuthenticated()).toBe(false);
|
||||
});
|
||||
|
||||
it('getAccessToken() returns undefined once the token has expired', async () => {
|
||||
sessionStorage.setItem('auth.accessToken', 'at-1');
|
||||
sessionStorage.setItem('auth.expiresAt', String(Date.now() - 1000));
|
||||
const service = TestBed.inject(AuthService);
|
||||
it('ensureSessionChecked() reports unauthenticated when the request itself fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
|
||||
|
||||
await expect(service.getAccessToken()).resolves.toBeUndefined();
|
||||
const service = TestBed.inject(AuthService);
|
||||
await expect(service.ensureSessionChecked()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('logout() posts to the backend and clears the authenticated state', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const service = TestBed.inject(AuthService);
|
||||
await service.logout();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/auth/logout', { method: 'POST' });
|
||||
expect(service.isAuthenticated()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,103 +1,46 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { generateCodeChallenge, generateRandomString } from './pkce';
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'auth.accessToken';
|
||||
const EXPIRES_AT_KEY = 'auth.expiresAt';
|
||||
const CODE_VERIFIER_KEY = 'auth.codeVerifier';
|
||||
const STATE_KEY = 'auth.state';
|
||||
|
||||
interface DiscoveryDocument {
|
||||
authorization_endpoint: string;
|
||||
}
|
||||
|
||||
interface SessionResponse {
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The IdP client backing this app is confidential (holds a client secret), so
|
||||
* the authorization-code-for-tokens exchange must happen server-side — see
|
||||
* `POST /api/v1/auth/session`. This service only performs the browser-side
|
||||
* Authorization Code + PKCE redirect and hands the resulting code + PKCE
|
||||
* verifier to the backend; it never sees or stores the client secret.
|
||||
* the entire Authorization Code + PKCE dance — including the PKCE verifier and
|
||||
* the resulting access token — is handled server-side (see
|
||||
* `GET /api/v1/auth/login`, `GET /api/v1/auth/callback`). The backend sets an
|
||||
* httpOnly session cookie; the browser never sees an access token at all.
|
||||
* This service therefore only triggers navigation and asks the backend
|
||||
* "is there a valid session?" — it holds no tokens or PKCE state itself.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private discoveryPromise: Promise<DiscoveryDocument> | undefined;
|
||||
readonly isAuthenticated = signal<boolean | undefined>(undefined);
|
||||
|
||||
readonly isAuthenticated = signal(this.hasValidAccessToken());
|
||||
private sessionCheck: Promise<boolean> | undefined;
|
||||
|
||||
private hasValidAccessToken(): boolean {
|
||||
const expiresAt = Number(sessionStorage.getItem(EXPIRES_AT_KEY) ?? 0);
|
||||
return !!sessionStorage.getItem(ACCESS_TOKEN_KEY) && Date.now() < expiresAt;
|
||||
login(): void {
|
||||
window.location.href = `${environment.apiBaseUrl}/auth/login`;
|
||||
}
|
||||
|
||||
private discover(): Promise<DiscoveryDocument> {
|
||||
if (!this.discoveryPromise) {
|
||||
const issuer = environment.oidc.issuer.replace(/\/$/, '');
|
||||
this.discoveryPromise = fetch(`${issuer}/.well-known/openid-configuration`).then((response) => response.json());
|
||||
}
|
||||
return this.discoveryPromise;
|
||||
}
|
||||
|
||||
async login(): Promise<void> {
|
||||
const codeVerifier = generateRandomString();
|
||||
const state = generateRandomString();
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||
sessionStorage.setItem(CODE_VERIFIER_KEY, codeVerifier);
|
||||
sessionStorage.setItem(STATE_KEY, state);
|
||||
|
||||
const discovery = await this.discover();
|
||||
const url = new URL(discovery.authorization_endpoint);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', environment.oidc.clientId);
|
||||
url.searchParams.set('redirect_uri', environment.oidc.redirectUri);
|
||||
url.searchParams.set('scope', environment.oidc.scope);
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('code_challenge', codeChallenge);
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
async completeLogin(): Promise<void> {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
const codeVerifier = sessionStorage.getItem(CODE_VERIFIER_KEY);
|
||||
const expectedState = sessionStorage.getItem(STATE_KEY);
|
||||
|
||||
if (!code || !state || !codeVerifier || state !== expectedState) {
|
||||
throw new Error('Invalid or missing OIDC callback parameters');
|
||||
}
|
||||
|
||||
const response = await fetch(`${environment.apiBaseUrl}/auth/session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, codeVerifier, redirectUri: environment.oidc.redirectUri }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to exchange the authorization code for a session');
|
||||
}
|
||||
|
||||
const session = (await response.json()) as SessionResponse;
|
||||
sessionStorage.setItem(ACCESS_TOKEN_KEY, session.accessToken);
|
||||
sessionStorage.setItem(EXPIRES_AT_KEY, String(Date.now() + session.expiresIn * 1000));
|
||||
sessionStorage.removeItem(CODE_VERIFIER_KEY);
|
||||
sessionStorage.removeItem(STATE_KEY);
|
||||
this.isAuthenticated.set(true);
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
sessionStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
sessionStorage.removeItem(EXPIRES_AT_KEY);
|
||||
async logout(): Promise<void> {
|
||||
await fetch(`${environment.apiBaseUrl}/auth/logout`, { method: 'POST' });
|
||||
this.sessionCheck = undefined;
|
||||
this.isAuthenticated.set(false);
|
||||
}
|
||||
|
||||
async getAccessToken(): Promise<string | undefined> {
|
||||
return this.hasValidAccessToken() ? (sessionStorage.getItem(ACCESS_TOKEN_KEY) ?? undefined) : undefined;
|
||||
ensureSessionChecked(): Promise<boolean> {
|
||||
if (!this.sessionCheck) {
|
||||
this.sessionCheck = this.checkSession();
|
||||
}
|
||||
return this.sessionCheck;
|
||||
}
|
||||
|
||||
private async checkSession(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${environment.apiBaseUrl}/users/me`);
|
||||
this.isAuthenticated.set(response.ok);
|
||||
return response.ok;
|
||||
} catch {
|
||||
this.isAuthenticated.set(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<p>Signing you in…</p>
|
||||
@@ -1,27 +0,0 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Callback } from './callback';
|
||||
import { AuthService } from '../auth.service';
|
||||
|
||||
describe('Callback', () => {
|
||||
it('completes the OIDC login and navigates to /trips', async () => {
|
||||
const authService = { completeLogin: vi.fn().mockResolvedValue(undefined) };
|
||||
const router = { navigateByUrl: vi.fn().mockResolvedValue(true) };
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Callback],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: authService },
|
||||
{ provide: Router, useValue: router },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(Callback);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(authService.completeLogin).toHaveBeenCalledTimes(1);
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith('/trips');
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-callback',
|
||||
templateUrl: './callback.html',
|
||||
})
|
||||
export class Callback implements OnInit {
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.authService.completeLogin();
|
||||
await this.router.navigateByUrl('/trips');
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateCodeChallenge, generateRandomString } from './pkce';
|
||||
|
||||
describe('pkce', () => {
|
||||
it('computes the RFC 7636 Appendix B S256 test vector', async () => {
|
||||
// https://datatracker.ietf.org/doc/html/rfc7636#appendix-B
|
||||
const codeVerifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
||||
const challenge = await generateCodeChallenge(codeVerifier);
|
||||
expect(challenge).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
|
||||
});
|
||||
|
||||
it('generates a URL-safe random string of the requested length family', () => {
|
||||
const value = generateRandomString();
|
||||
expect(value).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(value.length).toBeGreaterThanOrEqual(43);
|
||||
});
|
||||
|
||||
it('generates different values on each call', () => {
|
||||
expect(generateRandomString()).not.toBe(generateRandomString());
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
function base64UrlEncode(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function generateRandomString(byteLength = 32): string {
|
||||
const bytes = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(bytes);
|
||||
return base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
export async function generateCodeChallenge(codeVerifier: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
|
||||
return base64UrlEncode(new Uint8Array(digest));
|
||||
}
|
||||
Reference in New Issue
Block a user