From f45583f3ea21020e308a72265e42198124396c61 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 14 Jul 2026 17:32:48 +0200 Subject: [PATCH] SSO implementiert --- listify-api/.env.docker.example | 6 + listify-api/.env.example | 6 + listify-api/README.md | 50 ++++ listify-api/package-lock.json | 20 +- listify-api/package.json | 1 + listify-api/src/app.module.ts | 4 +- listify-api/src/audit/audit-log.types.ts | 4 - listify-api/src/auth/auth.controller.ts | 31 +++ listify-api/src/auth/auth.module.ts | 3 +- listify-api/src/auth/auth.service.spec.ts | 188 ++++++-------- listify-api/src/auth/auth.service.ts | 215 ++++------------ listify-api/src/auth/auth.types.ts | 12 - listify-api/src/auth/jwt-auth.guard.spec.ts | 34 ++- listify-api/src/auth/oidc.service.ts | 233 ++++++++++++++++++ listify-api/src/auth/user.entity.ts | 16 +- .../1782200000000-AddSsoSubjectToUsers.ts | 87 +++++++ .../list-templates.service.spec.ts | 2 - .../list-templates/list-templates.service.ts | 2 +- listify-api/src/lists/lists.service.spec.ts | 4 - listify-api/src/lists/lists.service.ts | 2 +- .../src/tasks/task-digest.service.spec.ts | 2 - listify-api/src/tasks/task-digest.service.ts | 1 - listify-api/test/app.e2e-spec.ts | 173 +++++++------ .../src/app/account/account.component.html | 2 +- listify-client/src/app/app.html | 9 - listify-client/src/app/app.routes.ts | 10 +- listify-client/src/app/auth/auth-page.scss | 34 ++- .../src/app/auth/auth.interceptor.ts | 5 +- listify-client/src/app/auth/auth.models.ts | 10 - listify-client/src/app/auth/auth.service.ts | 29 ++- .../src/app/auth/login/login.component.html | 88 ++----- .../src/app/auth/login/login.component.ts | 74 +----- .../sso-callback/sso-callback.component.html | 22 ++ .../sso-callback/sso-callback.component.ts | 115 +++++++++ .../verify-email/verify-email.component.html | 24 +- .../verify-email/verify-email.component.ts | 31 +-- 36 files changed, 896 insertions(+), 653 deletions(-) create mode 100644 listify-api/src/auth/oidc.service.ts create mode 100644 listify-api/src/database/migrations/1782200000000-AddSsoSubjectToUsers.ts create mode 100644 listify-client/src/app/auth/sso-callback/sso-callback.component.html create mode 100644 listify-client/src/app/auth/sso-callback/sso-callback.component.ts diff --git a/listify-api/.env.docker.example b/listify-api/.env.docker.example index 5671cb8..9eeedf3 100644 --- a/listify-api/.env.docker.example +++ b/listify-api/.env.docker.example @@ -18,6 +18,12 @@ JWT_REFRESH_SECRET=change-me-refresh-secret # Browser-URL, unter der der Container erreichbar ist. CLIENT_URL=http://localhost:8080 +OIDC_ISSUER_URL=https://auth.forgecore.work/realms/listify +OIDC_DISCOVERY_URL= +OIDC_CLIENT_ID=listify +OIDC_CLIENT_SECRET= +OIDC_CALLBACK_URL=http://localhost:8080/auth/sso/callback + MISTRAL_API_KEY= MISTRAL_AGENT_ID= diff --git a/listify-api/.env.example b/listify-api/.env.example index 605a999..66f312b 100644 --- a/listify-api/.env.example +++ b/listify-api/.env.example @@ -15,6 +15,12 @@ JWT_REFRESH_SECRET=change-me-refresh-secret CLIENT_URL=http://localhost:4200 +OIDC_ISSUER_URL=https://auth.forgecore.work/realms/Homelab/account +OIDC_DISCOVERY_URL= +OIDC_CLIENT_ID=listify +OIDC_CLIENT_SECRET= +OIDC_CALLBACK_URL=http://localhost:4200/auth/sso/callback + MCP_ACCESS_TOKEN= MISTRAL_API_KEY= diff --git a/listify-api/README.md b/listify-api/README.md index dc52fa5..7570f89 100644 --- a/listify-api/README.md +++ b/listify-api/README.md @@ -60,6 +60,56 @@ Configure the external MCP connector with `Authorization: Bearer $MCP_ACCESS_TOK Every Mistral response is stored in `assistant_chat_logs`. The table includes the sanitized provider request, the full raw provider response, the extracted assistant text sent back to the UI, response status and timing metadata. +## SSO mit Keycloak + +Listify nutzt OpenID Connect mit Authorization Code + PKCE. Bei Keycloak muss der Issuer immer auf den Realm zeigen, nicht nur auf die Basisdomain. + +### Keycloak Client + +1. In Keycloak im passenden Realm einen OpenID-Connect-Client fuer Listify anlegen, z. B. `listify`. +2. `Standard flow` aktivieren. PKCE mit `S256` erlauben oder erzwingen. +3. Scopes `openid`, `email` und `profile` verfuegbar machen. +4. Der User muss ein `email` Claim im ID Token erhalten. Ohne E-Mail lehnt Listify den Login ab. +5. Redirect URI fuer die Browser-URL eintragen: + +```text +http://localhost:4200/auth/sso/callback +``` + +Bei Docker/Reverse Proxy: + +```text +http://localhost:8080/auth/sso/callback +``` + +In Produktion muss hier die oeffentlich erreichbare Listify-URL stehen, z. B. `https://listify.example.com/auth/sso/callback`. + +### Listify Environment + +Bei einem Keycloak-Realm `listify` unter `https://auth.forgecore.work`: + +```bash +OIDC_ISSUER_URL=https://auth.forgecore.work/realms/listify +OIDC_DISCOVERY_URL= +OIDC_CLIENT_ID=listify +OIDC_CLIENT_SECRET= +OIDC_CALLBACK_URL=http://localhost:4200/auth/sso/callback +CLIENT_URL=http://localhost:4200 +``` + +Wenn dein Realm anders heisst, muss nur der Realm-Teil angepasst werden. Die Discovery-URL wird automatisch aus dem Issuer gebildet: + +```text +https://auth.forgecore.work/realms//.well-known/openid-configuration +``` + +Nur falls Keycloak hinter einem Proxy eine abweichende Discovery-URL liefert oder du sie explizit setzen willst: + +```bash +OIDC_ISSUER_URL=https://auth.forgecore.work/realms/listify +OIDC_DISCOVERY_URL=https://auth.forgecore.work/realms/listify/.well-known/openid-configuration +``` + ## Run tests ```bash diff --git a/listify-api/package-lock.json b/listify-api/package-lock.json index 316496b..a385109 100644 --- a/listify-api/package-lock.json +++ b/listify-api/package-lock.json @@ -22,6 +22,7 @@ "@types/web-push": "^3.6.4", "handlebars": "^4.7.9", "helmet": "^8.2.0", + "jose": "^6.2.3", "mysql2": "^3.22.5", "nodemailer": "^8.0.10", "passport-jwt": "^4.0.1", @@ -4563,7 +4564,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -4577,7 +4578,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -4894,7 +4895,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -6955,7 +6956,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -7223,6 +7224,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8006,7 +8008,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8035,7 +8037,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -8065,7 +8067,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -10646,7 +10648,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13411,7 +13413,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" diff --git a/listify-api/package.json b/listify-api/package.json index 5b164d0..35ca498 100644 --- a/listify-api/package.json +++ b/listify-api/package.json @@ -37,6 +37,7 @@ "@types/web-push": "^3.6.4", "handlebars": "^4.7.9", "helmet": "^8.2.0", + "jose": "^6.2.3", "mysql2": "^3.22.5", "nodemailer": "^8.0.10", "passport-jwt": "^4.0.1", diff --git a/listify-api/src/app.module.ts b/listify-api/src/app.module.ts index 0c55c41..84c32af 100644 --- a/listify-api/src/app.module.ts +++ b/listify-api/src/app.module.ts @@ -52,8 +52,8 @@ import { DatabaseLogger } from './database/database.logger'; database: configService.get('DB_DATABASE', 'listify'), autoLoadEntities: true, synchronize: true, - logging: parseDatabaseLogging(env.DB_LOGGING), - logger: new DatabaseLogger(databaseLoggerOptionsFromEnv(env)), + // logging: parseDatabaseLogging(env.DB_LOGGING), + // logger: new DatabaseLogger(databaseLoggerOptionsFromEnv(env)), maxQueryExecutionTime: slowQueryThresholdFromEnv(env), }; }, diff --git a/listify-api/src/audit/audit-log.types.ts b/listify-api/src/audit/audit-log.types.ts index dc70bc1..9460e50 100644 --- a/listify-api/src/audit/audit-log.types.ts +++ b/listify-api/src/audit/audit-log.types.ts @@ -1,9 +1,5 @@ export type AuditAction = - | 'user.registered' - | 'user.email_verified' - | 'user.verification_resent' | 'user.login_succeeded' - | 'user.login_failed' | 'user.token_refreshed' | 'user.onboarding_updated' | 'user.task_digest_updated' diff --git a/listify-api/src/auth/auth.controller.ts b/listify-api/src/auth/auth.controller.ts index 49fbcac..8adb93e 100644 --- a/listify-api/src/auth/auth.controller.ts +++ b/listify-api/src/auth/auth.controller.ts @@ -8,8 +8,10 @@ import { Post, Query, Req, + Res, UseGuards, } from '@nestjs/common'; +import type { Response } from 'express'; import type { AuthenticatedRequest } from './auth.types'; import { AuthService } from './auth.service'; import { LoginDto } from './dto/login.dto'; @@ -44,6 +46,35 @@ export class AuthController { return this.authService.login(loginDto); } + @Get('sso/login') + async ssoLogin(@Res() response: Response) { + response.redirect(await this.authService.startSsoLogin()); + } + + @Get('sso/callback') + async ssoCallback( + @Query('code') code: string | undefined, + @Query('state') state: string | undefined, + @Res() response: Response, + ) { + const authResponse = await this.authService.completeSsoLogin(code, state); + const clientUrl = process.env.CLIENT_URL ?? 'http://localhost:4200'; + const redirectUrl = new URL('/auth/sso/callback', clientUrl); + const fragment = new URLSearchParams({ + accessToken: authResponse.accessToken, + refreshToken: authResponse.refreshToken, + user: JSON.stringify(authResponse.user), + }); + + response.redirect(`${redirectUrl.toString()}#${fragment.toString()}`); + } + + @Post('sso/exchange') + @HttpCode(HttpStatus.OK) + ssoExchange(@Body() body: { code?: string; state?: string }) { + return this.authService.completeSsoLogin(body.code, body.state); + } + @Post('refresh') refresh(@Body() refreshTokenDto: RefreshTokenDto) { return this.authService.refresh(refreshTokenDto); diff --git a/listify-api/src/auth/auth.module.ts b/listify-api/src/auth/auth.module.ts index 5690d1c..c519559 100644 --- a/listify-api/src/auth/auth.module.ts +++ b/listify-api/src/auth/auth.module.ts @@ -7,6 +7,7 @@ import { RefreshTokenEntity } from './refresh-token.entity'; import { AuthService } from './auth.service'; import { JwtAuthGuard } from './jwt-auth.guard'; import { McpAuthGuard } from './mcp-auth.guard'; +import { OidcService } from './oidc.service'; import { UserEntity } from './user.entity'; @Module({ @@ -16,7 +17,7 @@ import { UserEntity } from './user.entity'; TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]), ], controllers: [AuthController], - providers: [AuthService, JwtAuthGuard, McpAuthGuard], + providers: [AuthService, OidcService, JwtAuthGuard, McpAuthGuard], exports: [AuthService, JwtAuthGuard, McpAuthGuard], }) export class AuthModule {} diff --git a/listify-api/src/auth/auth.service.spec.ts b/listify-api/src/auth/auth.service.spec.ts index b1dbac7..2fc3d64 100644 --- a/listify-api/src/auth/auth.service.spec.ts +++ b/listify-api/src/auth/auth.service.spec.ts @@ -4,27 +4,40 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { AuthTokenResponse, JwtTokenPayload } from './auth.types'; import { AuthService } from './auth.service'; -import { MailModule } from '../mail/mail.module'; -import { MailService } from '../mail/mail.service'; +import { OidcProfile, OidcService } from './oidc.service'; import { RefreshTokenEntity } from './refresh-token.entity'; import { UserEntity } from './user.entity'; import { InMemoryRepository } from '../testing/in-memory-repository'; +class FakeOidcService { + profile: OidcProfile = { + subject: 'oidc-user-1', + email: 'User@Example.com', + name: 'Test User', + }; + + createAuthorizationUrl = jest.fn( + async () => 'https://sso.example.test/authorize', + ); + exchangeCallback = jest.fn(async () => this.profile); +} + describe('AuthService', () => { let module: TestingModule; let authService: AuthService; - let mailService: MailService; let jwtService: JwtService; + let oidcService: FakeOidcService; beforeEach(async () => { + oidcService = new FakeOidcService(); module = await Test.createTestingModule({ - imports: [ - EventEmitterModule.forRoot(), - JwtModule.register({}), - MailModule, - ], + imports: [EventEmitterModule.forRoot(), JwtModule.register({})], providers: [ AuthService, + { + provide: OidcService, + useValue: oidcService, + }, { provide: getRepositoryToken(UserEntity), useValue: new InMemoryRepository(), @@ -38,7 +51,6 @@ describe('AuthService', () => { await module.init(); authService = module.get(AuthService); - mailService = module.get(MailService); jwtService = module.get(JwtService); }); @@ -46,50 +58,55 @@ describe('AuthService', () => { await module.close(); }); - it('registers a user and sends a verification email', async () => { - const response = await authService.register({ + it('starts the SSO login flow', async () => { + await expect(authService.startSsoLogin()).resolves.toBe( + 'https://sso.example.test/authorize', + ); + expect(oidcService.createAuthorizationUrl).toHaveBeenCalled(); + }); + + it('creates a local profile from the SSO callback', async () => { + const loginResponse = await authService.completeSsoLogin('code', 'state'); + + expect(loginResponse.accessToken).toBeDefined(); + expect(loginResponse.refreshToken).toBeDefined(); + expect(loginResponse.user.email).toBe('user@example.com'); + expect(loginResponse.user.name).toBe('Test User'); + expect(oidcService.exchangeCallback).toHaveBeenCalledWith('code', 'state'); + }); + + it('reuses the same local user for the same SSO subject', async () => { + const firstLogin = await authService.completeSsoLogin('code', 'state'); + oidcService.profile = { + subject: 'oidc-user-1', + email: 'renamed@example.com', + name: 'Renamed User', + }; + + const secondLogin = await authService.completeSsoLogin('code', 'state'); + + expect(secondLogin.user.id).toBe(firstLogin.user.id); + expect(secondLogin.user.email).toBe('renamed@example.com'); + expect(secondLogin.user.name).toBe('Renamed User'); + }); + + it('links an existing local user by email on first SSO login', async () => { + const firstLogin = await authService.completeSsoLogin('code', 'state'); + oidcService.profile = { + subject: 'oidc-user-2', email: 'User@Example.com', - name: 'Test User', - password: 'password123', - }); + name: 'Linked User', + }; - const sentEmails = mailService.getSentEmails(); + const secondLogin = await authService.completeSsoLogin('code', 'state'); - expect(response.user.email).toBe('user@example.com'); - expect(response.user.verified).toBe(false); - expect(sentEmails).toHaveLength(1); - expect(sentEmails[0].to).toBe('user@example.com'); - expect(sentEmails[0].verificationUrl).toContain('/verify-email?token='); + expect(secondLogin.user.id).toBe(firstLogin.user.id); + expect(secondLogin.user.email).toBe('user@example.com'); + expect(secondLogin.user.name).toBe('Linked User'); }); - it('rejects login before email verification', async () => { - await authService.register({ - email: 'user@example.com', - password: 'password123', - }); - - await expect( - authService.login({ - email: 'user@example.com', - password: 'password123', - }), - ).rejects.toThrow('Please verify your email before login.'); - }); - - it('verifies email and allows login afterwards', async () => { - await authService.register({ - email: 'user@example.com', - password: 'password123', - }); - - const verificationUrl = mailService.getSentEmails()[0].verificationUrl; - const token = new URL(verificationUrl).searchParams.get('token'); - - const verifyResponse = await authService.verifyEmail(token ?? undefined); - const loginResponse = await authService.login({ - email: 'user@example.com', - password: 'password123', - }); + it('issues SSO-backed JWTs on login', async () => { + const loginResponse = await authService.completeSsoLogin('code', 'state'); const accessPayload = jwtService.verify( loginResponse.accessToken, { @@ -103,7 +120,6 @@ describe('AuthService', () => { }, ); - expect(verifyResponse.user.verified).toBe(true); expect(loginResponse.accessToken).toBeDefined(); expect(loginResponse.refreshToken).toBeDefined(); expect(loginResponse.user.email).toBe('user@example.com'); @@ -114,19 +130,7 @@ describe('AuthService', () => { }); it('rotates refresh tokens and rejects reuse', async () => { - await authService.register({ - email: 'user@example.com', - password: 'password123', - }); - - const verificationUrl = mailService.getSentEmails()[0].verificationUrl; - const token = new URL(verificationUrl).searchParams.get('token'); - await authService.verifyEmail(token ?? undefined); - - const loginResponse = await authService.login({ - email: 'user@example.com', - password: 'password123', - }); + const loginResponse = await loginWithSso(); const refreshResponse = await authService.refresh({ refreshToken: loginResponse.refreshToken, }); @@ -140,19 +144,7 @@ describe('AuthService', () => { }); it('rejects access tokens on the refresh endpoint', async () => { - await authService.register({ - email: 'user@example.com', - password: 'password123', - }); - - const verificationUrl = mailService.getSentEmails()[0].verificationUrl; - const token = new URL(verificationUrl).searchParams.get('token'); - await authService.verifyEmail(token ?? undefined); - - const loginResponse = await authService.login({ - email: 'user@example.com', - password: 'password123', - }); + const loginResponse = await loginWithSso(); await expect( authService.refresh({ refreshToken: loginResponse.accessToken }), @@ -160,19 +152,7 @@ describe('AuthService', () => { }); it('validates access tokens', async () => { - await authService.register({ - email: 'user@example.com', - password: 'password123', - }); - - const verificationUrl = mailService.getSentEmails()[0].verificationUrl; - const token = new URL(verificationUrl).searchParams.get('token'); - await authService.verifyEmail(token ?? undefined); - - const loginResponse = await authService.login({ - email: 'user@example.com', - password: 'password123', - }); + const loginResponse = await loginWithSso(); const payload = await authService.verifyAccessToken( loginResponse.accessToken, ); @@ -184,33 +164,23 @@ describe('AuthService', () => { ).rejects.toThrow('Access token is invalid.'); }); - it('rejects duplicate registrations', async () => { - await authService.register({ - email: 'user@example.com', - password: 'password123', - }); - + it('rejects password registration and login endpoints', async () => { await expect( authService.register({ email: 'user@example.com', password: 'password123', }), - ).rejects.toThrow('Email is already registered.'); + ).rejects.toThrow('Registration is handled by the SSO provider.'); + + await expect( + authService.login({ + email: 'user@example.com', + password: 'password123', + }), + ).rejects.toThrow('Login is handled by SSO.'); }); - async function registerVerifiedUserAndLogin(): Promise { - await authService.register({ - email: 'user@example.com', - password: 'password123', - }); - - const verificationUrl = mailService.getSentEmails()[0].verificationUrl; - const token = new URL(verificationUrl).searchParams.get('token'); - await authService.verifyEmail(token ?? undefined); - - return authService.login({ - email: 'user@example.com', - password: 'password123', - }); + async function loginWithSso(): Promise { + return authService.completeSsoLogin('code', 'state'); } }); diff --git a/listify-api/src/auth/auth.service.ts b/listify-api/src/auth/auth.service.ts index 9858ac9..e813bfc 100644 --- a/listify-api/src/auth/auth.service.ts +++ b/listify-api/src/auth/auth.service.ts @@ -1,11 +1,10 @@ import { BadRequestException, - ConflictException, + GoneException, Injectable, Optional, UnauthorizedException, } from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto'; @@ -22,8 +21,8 @@ import { PublicUser, PublicUserSearchResult, } from './auth.types'; -import { AppEvents } from '../events/app-events'; import type { TaskDigestPreference } from '../tasks/task-digest.types'; +import { OidcProfile, OidcService } from './oidc.service'; import { RefreshTokenEntity } from './refresh-token.entity'; import { UserEntity } from './user.entity'; @@ -37,8 +36,8 @@ export class AuthService { process.env.JWT_REFRESH_SECRET ?? 'dev-refresh-secret'; constructor( - private readonly eventEmitter: EventEmitter2, private readonly jwtService: JwtService, + private readonly oidcService: OidcService, @InjectRepository(UserEntity) private readonly usersRepository: Repository, @InjectRepository(RefreshTokenEntity) @@ -50,145 +49,46 @@ export class AuthService { async register( registerDto: RegisterDto, ): Promise<{ message: string; user: PublicUser }> { - const email = this.normalizeEmail(registerDto.email); - const password = this.requirePassword(registerDto.password); - const name = this.normalizeName(registerDto.name); - - const existingUser = await this.usersRepository.findOne({ - where: { email }, - }); - - if (existingUser) { - throw new ConflictException('Email is already registered.'); - } - - const verificationToken = this.createToken(); - const user = this.usersRepository.create({ - id: randomUUID(), - email, - name, - passwordHash: this.hashPassword(password), - verificationToken, - verified: false, - }); - const savedUser = await this.usersRepository.save(user); - - await this.auditLogService?.record({ - actorUserId: savedUser.id, - actorEmail: savedUser.email, - action: 'user.registered', - entityType: 'user', - entityId: savedUser.id, - metadata: { verified: savedUser.verified }, - }); - - this.eventEmitter.emit(AppEvents.UserRegistered, { - email, - verificationUrl: this.createVerificationUrl(verificationToken), - }); - - return { - message: 'Registration successful. Please verify your email address.', - user: this.toPublicUser(savedUser), - }; + void registerDto; + throw new GoneException('Registration is handled by the SSO provider.'); } async verifyEmail( token?: string, ): Promise<{ message: string; user: PublicUser }> { - if (!token) { - throw new BadRequestException('Verification token is required.'); - } - - const user = await this.usersRepository.findOne({ - where: { verificationToken: token }, - }); - - if (!user) { - throw new BadRequestException('Verification token is invalid.'); - } - - user.verified = true; - user.verificationToken = null; - try { - const savedUser = await this.usersRepository.save(user); - - await this.auditLogService?.record({ - actorUserId: savedUser.id, - actorEmail: savedUser.email, - action: 'user.email_verified', - entityType: 'user', - entityId: savedUser.id, - }); - - return { - message: 'Email verified successfully.', - user: this.toPublicUser(savedUser), - }; - } catch { - throw new BadRequestException('user not saved.'); - } + void token; + throw new GoneException('Email verification is no longer required.'); } async resendVerificationEmail( resendVerificationDto: ResendVerificationDto, ): Promise<{ message: string }> { - const email = this.normalizeEmail(resendVerificationDto.email); - const message = - 'Falls ein unverifiziertes Konto mit dieser E-Mail existiert, wurde eine neue Verifizierungsmail versendet.'; - const user = await this.usersRepository.findOne({ where: { email } }); - - if (!user || user.verified) { - return { message }; - } - - user.verificationToken = this.createToken(); - const savedUser = await this.usersRepository.save(user); - - await this.auditLogService?.record({ - actorUserId: savedUser.id, - actorEmail: savedUser.email, - action: 'user.verification_resent', - entityType: 'user', - entityId: savedUser.id, - }); - - this.eventEmitter.emit(AppEvents.UserRegistered, { - email: savedUser.email, - verificationUrl: this.createVerificationUrl(savedUser.verificationToken!), - }); - - return { message }; + void resendVerificationDto; + throw new GoneException('Email verification is no longer required.'); } async login(loginDto: LoginDto): Promise { - const email = this.normalizeEmail(loginDto.email); - const password = this.requirePassword(loginDto.password); - const user = await this.usersRepository.findOne({ where: { email } }); + void loginDto; + throw new GoneException('Login is handled by SSO.'); + } - if (!user || !this.passwordMatches(password, user.passwordHash)) { - await this.auditLogService?.record({ - actorEmail: email, - action: 'user.login_failed', - entityType: 'user', - entityId: user?.id, - metadata: { reason: 'invalid_credentials' }, - }); - throw new UnauthorizedException('Invalid email or password.'); - } - - if (!user.verified) { - await this.auditLogService?.record({ - actorUserId: user.id, - actorEmail: user.email, - action: 'user.login_failed', - entityType: 'user', - entityId: user.id, - metadata: { reason: 'email_not_verified' }, - }); - throw new UnauthorizedException('Please verify your email before login.'); - } + startSsoLogin(): Promise { + return this.oidcService.createAuthorizationUrl(); + } + async completeSsoLogin( + code?: string, + state?: string, + ): Promise { + const profile = await this.oidcService.exchangeCallback(code, state); + const existingUser = + (await this.usersRepository.findOne({ + where: { oidcSubject: profile.subject }, + })) ?? + (await this.usersRepository.findOne({ + where: { email: this.normalizeEmail(profile.email) }, + })); + const user = await this.syncOidcUser(profile, existingUser); const response = { ...(await this.createAuthTokens(user)), user: this.toPublicUser(user), @@ -200,6 +100,7 @@ export class AuthService { action: 'user.login_succeeded', entityType: 'user', entityId: user.id, + metadata: { directory: 'oidc', oidcSubject: user.oidcSubject }, }); return response; @@ -227,7 +128,7 @@ export class AuthService { where: { id: payload.sub }, }); - if (!user || !user.verified) { + if (!user) { throw new UnauthorizedException('Refresh token is invalid.'); } @@ -263,7 +164,7 @@ export class AuthService { where: { id: payload.sub }, }); - if (!user || !user.verified) { + if (!user) { throw new UnauthorizedException('Access token is invalid.'); } @@ -309,10 +210,7 @@ export class AuthService { const pattern = `%${normalizedQuery}%`; const users = await this.usersRepository.find({ - where: [ - { verified: true, email: Like(pattern) }, - { verified: true, name: Like(pattern) }, - ], + where: [{ email: Like(pattern) }, { name: Like(pattern) }], order: { email: 'ASC' }, take: 10, }); @@ -414,30 +312,35 @@ export class AuthService { throw new BadRequestException('Task digest preference is invalid.'); } - private requirePassword(password?: string): string { - if (!password || password.length < 8) { - throw new BadRequestException( - 'Password must contain at least 8 characters.', - ); - } + private async syncOidcUser( + profile: OidcProfile, + existingUser?: UserEntity | null, + ): Promise { + const user = + existingUser ?? + this.usersRepository.create({ + id: randomUUID(), + onboardingCompleted: false, + taskDigestPreference: 'both', + }); - return password; + user.email = this.normalizeEmail(profile.email); + user.name = this.normalizeName(profile.name); + user.oidcSubject = profile.subject; + user.onboardingCompleted = user.onboardingCompleted === true; + user.taskDigestPreference = user.taskDigestPreference ?? 'both'; + + return this.usersRepository.save(user); } - private hashPassword(password: string): string { - const salt = randomBytes(16).toString('hex'); - const hash = scryptSync(password, salt, 64).toString('hex'); - return `${salt}:${hash}`; - } - - private passwordMatches(password: string, passwordHash: string): boolean { - const [salt, storedHash] = passwordHash.split(':'); + private secretMatches(secret: string, storedSecretHash: string): boolean { + const [salt, storedHash] = storedSecretHash.split(':'); if (!salt || !storedHash) { return false; } - const attemptedHash = scryptSync(password, salt, 64); + const attemptedHash = scryptSync(secret, salt, 64); const storedHashBuffer = Buffer.from(storedHash, 'hex'); return ( @@ -513,16 +416,7 @@ export class AuthService { } private tokenMatches(token: string, tokenHash: string): boolean { - return this.passwordMatches(token, tokenHash); - } - - private createToken(): string { - return randomBytes(32).toString('hex'); - } - - private createVerificationUrl(token: string): string { - const clientUrl = process.env.CLIENT_URL ?? 'http://localhost:4200'; - return `${clientUrl}/verify-email?token=${token}`; + return this.secretMatches(token, tokenHash); } private toPublicUser(user: UserEntity): PublicUser { @@ -530,7 +424,6 @@ export class AuthService { id: user.id, email: user.email, name: user.name ?? undefined, - verified: user.verified, onboardingCompleted: user.onboardingCompleted === true, taskDigestPreference: user.taskDigestPreference ?? 'both', }; diff --git a/listify-api/src/auth/auth.types.ts b/listify-api/src/auth/auth.types.ts index d92fca3..2e6e4ba 100644 --- a/listify-api/src/auth/auth.types.ts +++ b/listify-api/src/auth/auth.types.ts @@ -1,17 +1,6 @@ import { Request } from 'express'; import type { TaskDigestPreference } from '../tasks/task-digest.types'; -export interface AuthUser { - id: string; - email: string; - name?: string; - passwordHash: string; - verificationToken?: string; - verified: boolean; - onboardingCompleted: boolean; - taskDigestPreference: TaskDigestPreference; -} - export interface AuthTokens { accessToken: string; refreshToken: string; @@ -36,7 +25,6 @@ export interface PublicUser { id: string; email: string; name?: string; - verified: boolean; onboardingCompleted: boolean; taskDigestPreference: TaskDigestPreference; } diff --git a/listify-api/src/auth/jwt-auth.guard.spec.ts b/listify-api/src/auth/jwt-auth.guard.spec.ts index de01a03..db1df6d 100644 --- a/listify-api/src/auth/jwt-auth.guard.spec.ts +++ b/listify-api/src/auth/jwt-auth.guard.spec.ts @@ -6,8 +6,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { AuthService } from './auth.service'; import { AuthenticatedRequest } from './auth.types'; import { JwtAuthGuard } from './jwt-auth.guard'; -import { MailModule } from '../mail/mail.module'; -import { MailService } from '../mail/mail.service'; +import { OidcService } from './oidc.service'; import { RefreshTokenEntity } from './refresh-token.entity'; import { UserEntity } from './user.entity'; import { InMemoryRepository } from '../testing/in-memory-repository'; @@ -16,14 +15,26 @@ describe('JwtAuthGuard', () => { let module: TestingModule; let authService: AuthService; let guard: JwtAuthGuard; - let mailService: MailService; beforeEach(async () => { module = await Test.createTestingModule({ - imports: [EventEmitterModule.forRoot(), JwtModule.register({}), MailModule], + imports: [EventEmitterModule.forRoot(), JwtModule.register({})], providers: [ AuthService, JwtAuthGuard, + { + provide: OidcService, + useValue: { + createAuthorizationUrl: jest.fn( + async () => 'https://sso.example.test/authorize', + ), + exchangeCallback: jest.fn(async () => ({ + subject: 'oidc-user-1', + email: 'user@example.com', + name: 'Test User', + })), + }, + }, { provide: getRepositoryToken(UserEntity), useValue: new InMemoryRepository(), @@ -38,7 +49,6 @@ describe('JwtAuthGuard', () => { authService = module.get(AuthService); guard = module.get(JwtAuthGuard); - mailService = module.get(MailService); }); afterEach(async () => { @@ -78,19 +88,7 @@ describe('JwtAuthGuard', () => { accessToken: string; refreshToken: string; }> { - await authService.register({ - email: 'user@example.com', - password: 'password123', - }); - - const verificationUrl = mailService.getSentEmails()[0].verificationUrl; - const token = new URL(verificationUrl).searchParams.get('token'); - await authService.verifyEmail(token ?? undefined); - - return authService.login({ - email: 'user@example.com', - password: 'password123', - }); + return authService.completeSsoLogin('code', 'state'); } function createRequest( diff --git a/listify-api/src/auth/oidc.service.ts b/listify-api/src/auth/oidc.service.ts new file mode 100644 index 0000000..0e4f308 --- /dev/null +++ b/listify-api/src/auth/oidc.service.ts @@ -0,0 +1,233 @@ +import { + BadRequestException, + Injectable, + ServiceUnavailableException, +} from '@nestjs/common'; +import { createHash, randomBytes } from 'crypto'; + +type JoseModule = typeof import('jose'); +type RemoteJwkSet = ReturnType; + +export interface OidcProfile { + subject: string; + email: string; + name?: string; +} + +interface OidcDiscovery { + authorization_endpoint: string; + token_endpoint: string; + jwks_uri: string; + issuer: string; +} + +interface PendingOidcState { + codeVerifier: string; + nonce: string; + expiresAt: number; +} + +interface TokenResponse { + id_token?: string; + error?: string; + error_description?: string; +} + +@Injectable() +export class OidcService { + private readonly pendingStates = new Map(); + private discovery?: OidcDiscovery; + private jose?: Promise; + private jwks?: RemoteJwkSet; + + async createAuthorizationUrl(): Promise { + const config = this.getConfig(); + const discovery = await this.getDiscovery(config.discoveryUrl); + const state = this.createOpaqueToken(); + const nonce = this.createOpaqueToken(); + const codeVerifier = this.createOpaqueToken(); + const codeChallenge = this.codeChallenge(codeVerifier); + const authorizationUrl = new URL(discovery.authorization_endpoint); + + this.pendingStates.set(state, { + codeVerifier, + nonce, + expiresAt: Date.now() + 10 * 60 * 1000, + }); + this.deleteExpiredStates(); + + authorizationUrl.searchParams.set('response_type', 'code'); + authorizationUrl.searchParams.set('client_id', config.clientId); + authorizationUrl.searchParams.set('redirect_uri', config.callbackUrl); + authorizationUrl.searchParams.set('scope', 'openid email profile'); + authorizationUrl.searchParams.set('state', state); + authorizationUrl.searchParams.set('nonce', nonce); + authorizationUrl.searchParams.set('code_challenge', codeChallenge); + authorizationUrl.searchParams.set('code_challenge_method', 'S256'); + + return authorizationUrl.toString(); + } + + async exchangeCallback(code?: string, state?: string): Promise { + if (!code || !state) { + throw new BadRequestException('OIDC code and state are required.'); + } + + const pendingState = this.pendingStates.get(state); + this.pendingStates.delete(state); + + if (!pendingState || pendingState.expiresAt <= Date.now()) { + throw new BadRequestException('OIDC state is invalid or expired.'); + } + + const config = this.getConfig(); + const discovery = await this.getDiscovery(config.discoveryUrl); + const tokenResponse = await this.requestTokens( + discovery, + config, + code, + pendingState.codeVerifier, + ); + + if (!tokenResponse.id_token) { + throw new ServiceUnavailableException( + tokenResponse.error_description ?? + tokenResponse.error ?? + 'OIDC token response did not include an ID token.', + ); + } + + const [{ jwtVerify }, jwks] = await Promise.all([ + this.getJose(), + this.getJwks(discovery.jwks_uri), + ]); + const { payload } = await jwtVerify(tokenResponse.id_token, jwks, { + issuer: discovery.issuer, + audience: config.clientId, + }); + + if (payload.nonce !== pendingState.nonce) { + throw new BadRequestException('OIDC nonce is invalid.'); + } + + if (!payload.sub || typeof payload.sub !== 'string') { + throw new BadRequestException('OIDC subject is missing.'); + } + + const email = typeof payload.email === 'string' ? payload.email : undefined; + + if (!email) { + throw new BadRequestException('OIDC email claim is missing.'); + } + + return { + subject: payload.sub, + email, + name: typeof payload.name === 'string' ? payload.name : undefined, + }; + } + + private async requestTokens( + discovery: OidcDiscovery, + config: ReturnType, + code: string, + codeVerifier: string, + ): Promise { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: config.callbackUrl, + client_id: config.clientId, + code_verifier: codeVerifier, + }); + + if (config.clientSecret) { + body.set('client_secret', config.clientSecret); + } + + const response = await fetch(discovery.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + const payload = (await response.json().catch(() => ({}))) as TokenResponse; + + if (!response.ok) { + throw new ServiceUnavailableException( + payload.error_description ?? + payload.error ?? + 'OIDC token request failed.', + ); + } + + return payload; + } + + private async getDiscovery(discoveryUrl: string): Promise { + if (this.discovery) { + return this.discovery; + } + + const response = await fetch(discoveryUrl); + + if (!response.ok) { + throw new ServiceUnavailableException('OIDC discovery failed.'); + } + + this.discovery = (await response.json()) as OidcDiscovery; + return this.discovery; + } + + private async getJwks(jwksUri: string): Promise { + const { createRemoteJWKSet } = await this.getJose(); + + this.jwks ??= createRemoteJWKSet(new URL(jwksUri)); + return this.jwks; + } + + private getJose(): Promise { + this.jose ??= import('jose'); + return this.jose; + } + + private getConfig() { + const issuerUrl = process.env.OIDC_ISSUER_URL; + const explicitDiscoveryUrl = process.env.OIDC_DISCOVERY_URL; + const clientId = process.env.OIDC_CLIENT_ID; + const callbackUrl = process.env.OIDC_CALLBACK_URL; + + if (!issuerUrl || !clientId || !callbackUrl) { + throw new ServiceUnavailableException( + 'OIDC configuration is incomplete.', + ); + } + + return { + issuerUrl, + discoveryUrl: + explicitDiscoveryUrl ?? + `${issuerUrl.replace(/\/$/, '')}/.well-known/openid-configuration`, + clientId, + callbackUrl, + clientSecret: process.env.OIDC_CLIENT_SECRET, + }; + } + + private createOpaqueToken(): string { + return randomBytes(32).toString('base64url'); + } + + private codeChallenge(codeVerifier: string): string { + return createHash('sha256').update(codeVerifier).digest('base64url'); + } + + private deleteExpiredStates(): void { + const now = Date.now(); + + for (const [state, pendingState] of this.pendingStates.entries()) { + if (pendingState.expiresAt <= now) { + this.pendingStates.delete(state); + } + } + } +} diff --git a/listify-api/src/auth/user.entity.ts b/listify-api/src/auth/user.entity.ts index 9fa5235..29573c6 100644 --- a/listify-api/src/auth/user.entity.ts +++ b/listify-api/src/auth/user.entity.ts @@ -25,19 +25,13 @@ export class UserEntity { @Column({ type: 'varchar', length: 320 }) email!: string; + @Index({ unique: true }) + @Column({ type: 'varchar', length: 255, nullable: true, select: false }) + oidcSubject?: string | null; + @Column({ type: 'varchar', length: 160, nullable: true }) name?: string | null; - @Column({ type: 'varchar', length: 255 }) - passwordHash!: string; - - @Index({ unique: true }) - @Column({ type: 'varchar', length: 128, nullable: true }) - verificationToken?: string | null; - - @Column({ type: 'boolean', default: false }) - verified!: boolean; - @Column({ type: 'boolean', default: false }) onboardingCompleted!: boolean; @@ -51,7 +45,7 @@ export class UserEntity { taskDigestAfternoonProcessedDate?: string | null; @Index('IDX_users_mcp_api_key_hash', { unique: true }) - @Column({ type: 'varchar', length: 64, nullable: true }) + @Column({ type: 'varchar', length: 64, nullable: true, select: false }) mcpApiKeyHash?: string | null; @Column({ type: 'datetime', precision: 3, nullable: true }) diff --git a/listify-api/src/database/migrations/1782200000000-AddSsoSubjectToUsers.ts b/listify-api/src/database/migrations/1782200000000-AddSsoSubjectToUsers.ts new file mode 100644 index 0000000..b9f9d60 --- /dev/null +++ b/listify-api/src/database/migrations/1782200000000-AddSsoSubjectToUsers.ts @@ -0,0 +1,87 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSsoSubjectToUsers1782200000000 implements MigrationInterface { + name = 'AddSsoSubjectToUsers1782200000000'; + + public async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasTable('users'))) { + return; + } + + if (!(await queryRunner.hasColumn('users', 'oidcSubject'))) { + await queryRunner.query( + 'ALTER TABLE `users` ADD `oidcSubject` varchar(255) NULL', + ); + await queryRunner.query( + 'CREATE UNIQUE INDEX `IDX_users_oidc_subject` ON `users` (`oidcSubject`)', + ); + } + + await this.dropColumnIfExists(queryRunner, 'passwordHash'); + await this.dropColumnIfExists(queryRunner, 'verificationToken'); + await this.dropColumnIfExists(queryRunner, 'verified'); + await this.dropColumnIfExists(queryRunner, 'ldapUserId'); + } + + public async down(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasTable('users'))) { + return; + } + + await this.addColumnIfMissing( + queryRunner, + 'verified', + 'ALTER TABLE `users` ADD `verified` tinyint NOT NULL DEFAULT 0', + ); + await this.addColumnIfMissing( + queryRunner, + 'verificationToken', + 'ALTER TABLE `users` ADD `verificationToken` varchar(128) NULL', + ); + await this.addColumnIfMissing( + queryRunner, + 'passwordHash', + 'ALTER TABLE `users` ADD `passwordHash` varchar(255) NULL', + ); + + if (await queryRunner.hasColumn('users', 'oidcSubject')) { + await this.dropIndexIfExists(queryRunner, 'IDX_users_oidc_subject'); + await queryRunner.query('ALTER TABLE `users` DROP COLUMN `oidcSubject`'); + } + } + + private async dropColumnIfExists( + queryRunner: QueryRunner, + columnName: string, + ): Promise { + if (await queryRunner.hasColumn('users', columnName)) { + await queryRunner.query( + `ALTER TABLE \`users\` DROP COLUMN \`${columnName}\``, + ); + } + } + + private async addColumnIfMissing( + queryRunner: QueryRunner, + columnName: string, + query: string, + ): Promise { + if (!(await queryRunner.hasColumn('users', columnName))) { + await queryRunner.query(query); + } + } + + private async dropIndexIfExists( + queryRunner: QueryRunner, + indexName: string, + ): Promise { + const indexes = (await queryRunner.query( + 'SELECT INDEX_NAME FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?', + ['users', indexName], + )) as unknown[]; + + if (indexes.length) { + await queryRunner.query(`DROP INDEX \`${indexName}\` ON \`users\``); + } + } +} diff --git a/listify-api/src/list-templates/list-templates.service.spec.ts b/listify-api/src/list-templates/list-templates.service.spec.ts index 1795bd4..1a8eb27 100644 --- a/listify-api/src/list-templates/list-templates.service.spec.ts +++ b/listify-api/src/list-templates/list-templates.service.spec.ts @@ -92,8 +92,6 @@ describe('ListTemplatesService', () => { await usersRepository.save({ id: 'user-2', email: 'collaborator@example.com', - passwordHash: 'hash', - verified: true, onboardingCompleted: false, createdAt: new Date(), updatedAt: new Date(), diff --git a/listify-api/src/list-templates/list-templates.service.ts b/listify-api/src/list-templates/list-templates.service.ts index c47beb0..bd619af 100644 --- a/listify-api/src/list-templates/list-templates.service.ts +++ b/listify-api/src/list-templates/list-templates.service.ts @@ -217,7 +217,7 @@ export class ListTemplatesService { where: { id: targetUserId }, }); - if (!targetUser || !targetUser.verified) { + if (!targetUser) { throw new NotFoundException('User was not found.'); } diff --git a/listify-api/src/lists/lists.service.spec.ts b/listify-api/src/lists/lists.service.spec.ts index 93e1ae4..a2c5b48 100644 --- a/listify-api/src/lists/lists.service.spec.ts +++ b/listify-api/src/lists/lists.service.spec.ts @@ -179,8 +179,6 @@ describe('ListsService', () => { await usersRepository.save({ id: 'user-2', email: 'collaborator@example.com', - passwordHash: 'hash', - verified: true, onboardingCompleted: false, createdAt: new Date(), updatedAt: new Date(), @@ -351,8 +349,6 @@ describe('ListsService', () => { await usersRepository.save({ id: 'user-2', email: 'collaborator@example.com', - passwordHash: 'hash', - verified: true, onboardingCompleted: false, createdAt: new Date(), updatedAt: new Date(), diff --git a/listify-api/src/lists/lists.service.ts b/listify-api/src/lists/lists.service.ts index 7d0ec80..e6f8a66 100644 --- a/listify-api/src/lists/lists.service.ts +++ b/listify-api/src/lists/lists.service.ts @@ -380,7 +380,7 @@ export class ListsService { where: { id: targetUserId }, }); - if (!targetUser || !targetUser.verified) { + if (!targetUser) { throw new NotFoundException('User was not found.'); } diff --git a/listify-api/src/tasks/task-digest.service.spec.ts b/listify-api/src/tasks/task-digest.service.spec.ts index 2cb774d..cc1dd06 100644 --- a/listify-api/src/tasks/task-digest.service.spec.ts +++ b/listify-api/src/tasks/task-digest.service.spec.ts @@ -226,8 +226,6 @@ describe('TaskDigestService', () => { id: overrides.id ?? 'owner-1', email: overrides.email ?? 'owner@example.com', name: overrides.name ?? 'Owner', - passwordHash: 'hash', - verified: overrides.verified ?? true, onboardingCompleted: false, taskDigestPreference: overrides.taskDigestPreference ?? 'both', taskDigestMorningProcessedDate: diff --git a/listify-api/src/tasks/task-digest.service.ts b/listify-api/src/tasks/task-digest.service.ts index c58701a..56bb306 100644 --- a/listify-api/src/tasks/task-digest.service.ts +++ b/listify-api/src/tasks/task-digest.service.ts @@ -75,7 +75,6 @@ export class TaskDigestService { try { const dateKey = this.dateKey(now); const users = await this.usersRepository.find({ - where: { verified: true }, order: { email: 'ASC' }, }); diff --git a/listify-api/test/app.e2e-spec.ts b/listify-api/test/app.e2e-spec.ts index 9d063b6..e2d9215 100644 --- a/listify-api/test/app.e2e-spec.ts +++ b/listify-api/test/app.e2e-spec.ts @@ -2,8 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import { App } from 'supertest/types'; +import { DataSource } from 'typeorm'; import { AppModule } from './../src/app.module'; -import { MailService } from './../src/mail/mail.service'; +import { OidcProfile, OidcService } from '../src/auth/oidc.service'; interface AuthResponseBody { accessToken?: string; @@ -11,7 +12,6 @@ interface AuthResponseBody { user: { id?: string; email: string; - verified: boolean; }; } @@ -28,16 +28,36 @@ interface ListTemplateResponseBody { describe('AppController (e2e)', () => { let app: INestApplication; - let mailService: MailService; + let oidcService: { + createAuthorizationUrl: jest.Mock, []>; + exchangeCallback: jest.Mock< + Promise, + [string | undefined, string | undefined] + >; + }; beforeEach(async () => { + oidcService = { + createAuthorizationUrl: jest.fn( + async () => 'https://sso.example.test/authorize', + ), + exchangeCallback: jest.fn(async () => ({ + subject: 'oidc-default', + email: 'default@example.com', + name: 'Default User', + })), + }; + const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], - }).compile(); + }) + .overrideProvider(OidcService) + .useValue(oidcService) + .compile(); - mailService = moduleFixture.get(MailService); app = moduleFixture.createNestApplication(); await app.init(); + await ensureSsoSchema(app.get(DataSource)); }); it('/ (GET)', () => { @@ -47,50 +67,13 @@ describe('AppController (e2e)', () => { .expect('Hello World!'); }); - it('/auth register, verify and login', async () => { - const registerResponse = await request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: 'user@example.com', - password: 'password123', - }) - .expect(201); + it('/auth sso callback and refresh', async () => { + const email = uniqueEmail('auth-user'); + const loginBody = await loginWithSso(email); - const registerBody = registerResponse.body as unknown as AuthResponseBody; - expect(registerBody.user.email).toBe('user@example.com'); - expect(registerBody.user.verified).toBe(false); - - await request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: 'user@example.com', - password: 'password123', - }) - .expect(401); - - const verificationUrl = mailService.getSentEmails()[0].verificationUrl; - const token = new URL(verificationUrl).searchParams.get('token'); - - const verifyResponse = await request(app.getHttpServer()) - .get('/auth/verify-email') - .query({ token }) - .expect(200); - - const verifyBody = verifyResponse.body as unknown as AuthResponseBody; - expect(verifyBody.user.verified).toBe(true); - - const loginResponse = await request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: 'user@example.com', - password: 'password123', - }) - .expect(200); - - const loginBody = loginResponse.body as unknown as AuthResponseBody; expect(loginBody.accessToken).toBeDefined(); expect(loginBody.refreshToken).toBeDefined(); - expect(loginBody.user.email).toBe('user@example.com'); + expect(loginBody.user.email).toBe(email); const refreshResponse = await request(app.getHttpServer()) .post('/auth/refresh') @@ -113,8 +96,8 @@ describe('AppController (e2e)', () => { }); it('/list-templates creates, updates and uses a template', async () => { - const accessToken = await registerVerifiedUserAndGetAccessToken( - 'template-user@example.com', + const accessToken = await loginWithSsoAndGetAccessToken( + uniqueEmail('template-user'), ); const initialTemplatesResponse = await request(app.getHttpServer()) @@ -172,8 +155,8 @@ describe('AppController (e2e)', () => { }); it('/lists creates, updates and reads a concrete list', async () => { - const accessToken = await registerVerifiedUserAndGetAccessToken( - 'list-user@example.com', + const accessToken = await loginWithSsoAndGetAccessToken( + uniqueEmail('list-user'), ); const createListResponse = await request(app.getHttpServer()) @@ -222,41 +205,75 @@ describe('AppController (e2e)', () => { expect(fetchedList.items[0].checked).toBe(true); }); - async function registerVerifiedUserAndGetAccessToken( + async function loginWithSsoAndGetAccessToken( email: string, ): Promise { - await request(app.getHttpServer()) - .post('/auth/register') - .send({ - email, - password: 'password123', - }) - .expect(201); - - const verificationUrl = - mailService.getSentEmails()[mailService.getSentEmails().length - 1] - .verificationUrl; - const token = new URL(verificationUrl).searchParams.get('token'); - - await request(app.getHttpServer()) - .get('/auth/verify-email') - .query({ token }) - .expect(200); - - const loginResponse = await request(app.getHttpServer()) - .post('/auth/login') - .send({ - email, - password: 'password123', - }) - .expect(200); - - const loginBody = loginResponse.body as unknown as AuthResponseBody; + const loginBody = await loginWithSso(email); expect(loginBody.accessToken).toBeDefined(); return loginBody.accessToken ?? ''; } + async function loginWithSso(email: string): Promise { + oidcService.exchangeCallback.mockResolvedValueOnce({ + subject: `sub-${email}`, + email, + name: 'Test User', + }); + + const exchangeResponse = await request(app.getHttpServer()) + .post('/auth/sso/exchange') + .send({ code: 'code', state: 'state' }) + .expect(200); + + return exchangeResponse.body as unknown as AuthResponseBody; + } + + function uniqueEmail(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}@example.com`; + } + + async function ensureSsoSchema(dataSource: DataSource): Promise { + const usersTables = (await dataSource.query( + "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users'", + )) as unknown[]; + + if (!usersTables.length) { + return; + } + + const oidcSubjectColumns = (await dataSource.query( + "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'oidcSubject'", + )) as unknown[]; + + if (!oidcSubjectColumns.length) { + await dataSource.query( + 'ALTER TABLE `users` ADD `oidcSubject` varchar(255) NULL', + ); + await dataSource.query( + 'CREATE UNIQUE INDEX `IDX_users_oidc_subject` ON `users` (`oidcSubject`)', + ); + } + + await dropColumnIfExists(dataSource, 'passwordHash'); + await dropColumnIfExists(dataSource, 'verificationToken'); + await dropColumnIfExists(dataSource, 'verified'); + } + + async function dropColumnIfExists( + dataSource: DataSource, + columnName: string, + ): Promise { + const columns = (await dataSource.query( + 'SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', + ['users', columnName], + )) as unknown[]; + + if (columns.length) { + await dataSource.query(`ALTER TABLE \`users\` DROP COLUMN \`${columnName}\``); + } + } + afterEach(async () => { await app.close(); }); diff --git a/listify-client/src/app/account/account.component.html b/listify-client/src/app/account/account.component.html index c2a866c..a1a27f0 100644 --- a/listify-client/src/app/account/account.component.html +++ b/listify-client/src/app/account/account.component.html @@ -8,7 +8,7 @@
- {{ auth.user()?.verified ? 'E-Mail verifiziert' : 'E-Mail nicht verifiziert' }} + SSO-Konto aktiv
diff --git a/listify-client/src/app/app.html b/listify-client/src/app/app.html index 4683910..219588d 100644 --- a/listify-client/src/app/app.html +++ b/listify-client/src/app/app.html @@ -41,15 +41,6 @@ Login - - - Registrieren - } diff --git a/listify-client/src/app/app.routes.ts b/listify-client/src/app/app.routes.ts index 0c72aec..72c7965 100644 --- a/listify-client/src/app/app.routes.ts +++ b/listify-client/src/app/app.routes.ts @@ -2,8 +2,7 @@ import { Routes } from '@angular/router'; import { authGuard } from './auth/auth.guard'; import { unauthGuard } from './auth/unauth.guard'; import { LoginComponent } from './auth/login/login.component'; -import { RegisterComponent } from './auth/register/register.component'; -import { VerifyEmailComponent } from './auth/verify-email/verify-email.component'; +import { SsoCallbackComponent } from './auth/sso-callback/sso-callback.component'; import { ListDetailComponent } from './lists/list-detail/list-detail.component'; import { ListsComponent } from './lists/lists.component'; import { TemplatesComponent } from './templates/templates.component'; @@ -12,12 +11,7 @@ import { TemplateDetailComponent } from './templates/template-detail/template-de export const routes: Routes = [ { path: '', pathMatch: 'full', redirectTo: 'dashboard' }, { path: 'login', component: LoginComponent, canActivate: [unauthGuard] }, - { path: 'register', component: RegisterComponent, canActivate: [unauthGuard] }, - { path: 'verify-email', component: VerifyEmailComponent, canActivate: [unauthGuard] }, - { - path: 'auth', - children: [{ path: 'verify-email', component: VerifyEmailComponent }], - }, + { path: 'auth/sso/callback', component: SsoCallbackComponent }, { path: 'dashboard', loadComponent: () => diff --git a/listify-client/src/app/auth/auth-page.scss b/listify-client/src/app/auth/auth-page.scss index 6dacd3a..a5227d4 100644 --- a/listify-client/src/app/auth/auth-page.scss +++ b/listify-client/src/app/auth/auth-page.scss @@ -9,8 +9,16 @@ align-items: start; padding: 1.25rem; background: - linear-gradient(140deg, color-mix(in srgb, var(--mat-sys-primary) 14%, transparent), transparent 38%), - linear-gradient(320deg, color-mix(in srgb, var(--mat-sys-tertiary) 12%, transparent), transparent 36%), + linear-gradient( + 140deg, + color-mix(in srgb, var(--mat-sys-primary) 14%, transparent), + transparent 38% + ), + linear-gradient( + 320deg, + color-mix(in srgb, var(--mat-sys-tertiary) 12%, transparent), + transparent 36% + ), var(--mat-sys-surface-container); } @@ -21,7 +29,9 @@ border-radius: 8px; background: var(--mat-sys-surface); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); - transition: box-shadow 0.3s ease, transform 0.3s ease; + transition: + box-shadow 0.3s ease, + transform 0.3s ease; } .auth-card:hover { @@ -54,7 +64,11 @@ border: 1px solid color-mix(in srgb, var(--mat-sys-primary) 16%, transparent); border-radius: 8px; background: - linear-gradient(145deg, color-mix(in srgb, var(--mat-sys-primary) 18%, transparent), transparent), + linear-gradient( + 145deg, + color-mix(in srgb, var(--mat-sys-primary) 18%, transparent), + transparent + ), var(--mat-sys-surface-container-low); color: var(--mat-sys-primary); } @@ -101,6 +115,18 @@ margin-right: 0.5rem; } +.sso-login-button { + width: 100%; + min-height: 48px; + margin-top: 1rem; + border-radius: 8px; +} + +.sso-login-button mat-progress-spinner { + display: inline-flex; + margin-right: 0.5rem; +} + .auth-card mat-card-actions { flex-wrap: wrap; gap: 0.75rem; diff --git a/listify-client/src/app/auth/auth.interceptor.ts b/listify-client/src/app/auth/auth.interceptor.ts index d6f9e44..3edd3c0 100644 --- a/listify-client/src/app/auth/auth.interceptor.ts +++ b/listify-client/src/app/auth/auth.interceptor.ts @@ -25,9 +25,7 @@ export const authInterceptor: HttpInterceptorFn = ( } return auth.refreshSession().pipe( - switchMap((response) => - next(withAccessToken(request, response.accessToken)), - ), + switchMap((response) => next(withAccessToken(request, response.accessToken))), catchError((refreshError: unknown) => { auth.logout(); void router.navigateByUrl('/login'); @@ -70,6 +68,7 @@ function isAuthRequest(request: HttpRequest): boolean { return [ '/api/auth/login', '/api/auth/register', + '/api/auth/sso', '/api/auth/refresh', '/api/auth/resend-verification', '/api/auth/verify-email', diff --git a/listify-client/src/app/auth/auth.models.ts b/listify-client/src/app/auth/auth.models.ts index 2eae034..3e78b23 100644 --- a/listify-client/src/app/auth/auth.models.ts +++ b/listify-client/src/app/auth/auth.models.ts @@ -4,7 +4,6 @@ export interface PublicUser { id: string; email: string; name?: string; - verified: boolean; onboardingCompleted: boolean; taskDigestPreference: TaskDigestPreference; } @@ -26,15 +25,6 @@ export interface RegisterResponse { user: PublicUser; } -export interface VerifyEmailResponse { - message: string; - user: PublicUser; -} - -export interface ResendVerificationResponse { - message: string; -} - export interface LoginRequest { email: string; password: string; diff --git a/listify-client/src/app/auth/auth.service.ts b/listify-client/src/app/auth/auth.service.ts index 2695004..8820b11 100644 --- a/listify-client/src/app/auth/auth.service.ts +++ b/listify-client/src/app/auth/auth.service.ts @@ -8,9 +8,7 @@ import { PublicUserSearchResult, RegisterRequest, RegisterResponse, - ResendVerificationResponse, TaskDigestPreference, - VerifyEmailResponse, } from './auth.models'; const ACCESS_TOKEN_KEY = 'listify.accessToken'; @@ -33,21 +31,26 @@ export class AuthService { .pipe(tap((response) => this.storeSession(response))); } + startSsoLogin(): void { + if (typeof window !== 'undefined') { + window.location.href = `${this.apiUrl}/sso/login`; + } + } + + completeSsoLogin(response: AuthTokenResponse): void { + this.storeSession(response); + } + + exchangeSsoCode(code: string, state: string): Observable { + return this.http + .post(`${this.apiUrl}/sso/exchange`, { code, state }) + .pipe(tap((response) => this.storeSession(response))); + } + register(data: RegisterRequest): Observable { return this.http.post(`${this.apiUrl}/register`, data); } - verifyEmail(token: string): Observable { - const params = new HttpParams().set('token', token); - return this.http.get(`${this.apiUrl}/verify-email`, { params }); - } - - resendVerificationEmail(email: string): Observable { - return this.http.post(`${this.apiUrl}/resend-verification`, { - email, - }); - } - loadCurrentUser(): Observable { return this.http.get(`${this.apiUrl}/me`).pipe(tap((user) => this.storeUser(user))); } diff --git a/listify-client/src/app/auth/login/login.component.html b/listify-client/src/app/auth/login/login.component.html index 13eefcf..8a105f7 100644 --- a/listify-client/src/app/auth/login/login.component.html +++ b/listify-client/src/app/auth/login/login.component.html @@ -5,81 +5,25 @@
Willkommen zurueck - Melden Sie sich mit Ihrem Listify-Konto an + Melden Sie sich mit Ihrem SSO-Konto an -
- - E-Mail - - - @if (form.controls.email.hasError('required')) { - E-Mail ist erforderlich - } @else if (form.controls.email.hasError('email')) { - Bitte geben Sie eine gueltige E-Mail ein - } - - - - Passwort - - - @if (form.controls.password.hasError('required')) { - Passwort ist erforderlich - } @else if (form.controls.password.hasError('minlength')) { - Mindestens 8 Zeichen - } - - - - -
- oder -
- - -
+
- - - Neu hier? - - Konto erstellen - - diff --git a/listify-client/src/app/auth/login/login.component.ts b/listify-client/src/app/auth/login/login.component.ts index bd5a479..7b875b8 100644 --- a/listify-client/src/app/auth/login/login.component.ts +++ b/listify-client/src/app/auth/login/login.component.ts @@ -1,45 +1,21 @@ import { Component, inject, OnInit } from '@angular/core'; -import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; -import { Router, RouterLink } from '@angular/router'; -import { finalize } from 'rxjs'; +import { Router } from '@angular/router'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; -import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { AuthService } from '../auth.service'; -import { getAuthErrorMessage } from '../error-message'; -import { OnboardingService } from '../../onboarding/onboarding.service'; @Component({ selector: 'app-login', - imports: [ - ReactiveFormsModule, - RouterLink, - MatButtonModule, - MatCardModule, - MatFormFieldModule, - MatIconModule, - MatInputModule, - MatProgressSpinnerModule, - MatSnackBarModule, - ], + imports: [MatButtonModule, MatCardModule, MatIconModule, MatProgressSpinnerModule], templateUrl: './login.component.html', styleUrl: '../auth-page.scss', }) export class LoginComponent implements OnInit { private readonly auth = inject(AuthService); - private readonly formBuilder = inject(NonNullableFormBuilder); private readonly router = inject(Router); - private readonly snackBar = inject(MatSnackBar); - private readonly onboarding = inject(OnboardingService); - protected readonly form = this.formBuilder.group({ - email: ['', [Validators.required, Validators.email]], - password: ['', [Validators.required, Validators.minLength(8)]], - }); protected loading = false; ngOnInit(): void { @@ -47,51 +23,9 @@ export class LoginComponent implements OnInit { void this.router.navigateByUrl('/lists'); } } - protected resendingVerification = false; - protected hidePassword = true; - - submit(): void { - if (this.form.invalid) { - this.form.markAllAsTouched(); - return; - } + loginWithSso(): void { this.loading = true; - this.auth - .login(this.form.getRawValue()) - .pipe(finalize(() => (this.loading = false))) - .subscribe({ - next: () => { - this.snackBar.open('Login erfolgreich.', 'OK', { duration: 3000 }); - if (!this.onboarding.startForCurrentUser()) { - void this.router.navigateByUrl('/account'); - } - }, - error: (error: unknown) => { - this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); - }, - }); - } - - resendVerificationEmail(): void { - const emailControl = this.form.controls.email; - - if (emailControl.invalid) { - emailControl.markAsTouched(); - return; - } - - this.resendingVerification = true; - this.auth - .resendVerificationEmail(emailControl.value) - .pipe(finalize(() => (this.resendingVerification = false))) - .subscribe({ - next: (response) => { - this.snackBar.open(response.message, 'OK', { duration: 6000 }); - }, - error: (error: unknown) => { - this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); - }, - }); + this.auth.startSsoLogin(); } } diff --git a/listify-client/src/app/auth/sso-callback/sso-callback.component.html b/listify-client/src/app/auth/sso-callback/sso-callback.component.html new file mode 100644 index 0000000..2d27c6a --- /dev/null +++ b/listify-client/src/app/auth/sso-callback/sso-callback.component.html @@ -0,0 +1,22 @@ +
+ + + + + {{ failed ? 'Anmeldung fehlgeschlagen' : 'Anmeldung wird abgeschlossen' }} + + + + @if (!failed) { + + + + } @else if (errorMessage) { + +

{{ errorMessage }}

+
+ } +
+
diff --git a/listify-client/src/app/auth/sso-callback/sso-callback.component.ts b/listify-client/src/app/auth/sso-callback/sso-callback.component.ts new file mode 100644 index 0000000..db94488 --- /dev/null +++ b/listify-client/src/app/auth/sso-callback/sso-callback.component.ts @@ -0,0 +1,115 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Router } from '@angular/router'; +import { finalize } from 'rxjs'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { AuthTokenResponse } from '../auth.models'; +import { AuthService } from '../auth.service'; +import { OnboardingService } from '../../onboarding/onboarding.service'; + +@Component({ + selector: 'app-sso-callback', + imports: [MatCardModule, MatIconModule, MatProgressSpinnerModule], + templateUrl: './sso-callback.component.html', + styleUrl: '../auth-page.scss', +}) +export class SsoCallbackComponent implements OnInit { + private readonly auth = inject(AuthService); + private readonly onboarding = inject(OnboardingService); + private readonly router = inject(Router); + + protected failed = false; + protected loading = false; + protected errorMessage = ''; + + ngOnInit(): void { + if (this.exchangeAuthorizationCode()) { + return; + } + + const response = this.readAuthResponse(); + + if (!response) { + this.failed = true; + this.errorMessage = 'Die SSO-Antwort war unvollstaendig.'; + window.setTimeout(() => void this.router.navigateByUrl('/login'), 2000); + return; + } + + this.auth.completeSsoLogin(response); + + if (!this.onboarding.startForCurrentUser()) { + void this.router.navigateByUrl('/account'); + } + } + + private exchangeAuthorizationCode(): boolean { + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + const state = params.get('state'); + + if (!code || !state) { + return false; + } + + this.loading = true; + this.auth + .exchangeSsoCode(code, state) + .pipe(finalize(() => (this.loading = false))) + .subscribe({ + next: () => this.completeLoginNavigation(), + error: (error: unknown) => { + this.failed = true; + this.errorMessage = this.toErrorMessage(error); + window.setTimeout(() => void this.router.navigateByUrl('/login'), 3000); + }, + }); + + return true; + } + + private completeLoginNavigation(): void { + window.history.replaceState(null, '', '/auth/sso/callback'); + + if (!this.onboarding.startForCurrentUser()) { + void this.router.navigateByUrl('/account'); + } + } + + private readAuthResponse(): AuthTokenResponse | null { + const params = new URLSearchParams(window.location.hash.replace(/^#/, '')); + const accessToken = params.get('accessToken'); + const refreshToken = params.get('refreshToken'); + const userJson = params.get('user'); + + if (!accessToken || !refreshToken || !userJson) { + return null; + } + + try { + return { + accessToken, + refreshToken, + user: JSON.parse(userJson) as AuthTokenResponse['user'], + }; + } catch { + return null; + } + } + + private toErrorMessage(error: unknown): string { + if (error instanceof HttpErrorResponse) { + const responseError = error.error as { message?: unknown } | null; + + if (typeof responseError?.message === 'string') { + return responseError.message; + } + + return error.message; + } + + return 'Die SSO-Anmeldung konnte nicht abgeschlossen werden.'; + } +} diff --git a/listify-client/src/app/auth/verify-email/verify-email.component.html b/listify-client/src/app/auth/verify-email/verify-email.component.html index 8b578ae..676b88a 100644 --- a/listify-client/src/app/auth/verify-email/verify-email.component.html +++ b/listify-client/src/app/auth/verify-email/verify-email.component.html @@ -9,29 +9,17 @@ -
- @if (state() === 'loading') { - - } @else if (state() === 'success') { - - } @else { - - } - +
+

{{ message() }}

- @if (state() === 'success') { - - - Zum Login - - } @else if (state() !== 'loading') { - Neu registrieren - Zum Login - } + + + Zum Login + diff --git a/listify-client/src/app/auth/verify-email/verify-email.component.ts b/listify-client/src/app/auth/verify-email/verify-email.component.ts index 7607bb1..a8546bc 100644 --- a/listify-client/src/app/auth/verify-email/verify-email.component.ts +++ b/listify-client/src/app/auth/verify-email/verify-email.component.ts @@ -1,13 +1,9 @@ import { Component, OnInit, inject, signal } from '@angular/core'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { AuthService } from '../auth.service'; -import { getAuthErrorMessage } from '../error-message'; - -type VerificationState = 'loading' | 'success' | 'error' | 'missing-token'; @Component({ selector: 'app-verify-email', @@ -16,18 +12,15 @@ type VerificationState = 'loading' | 'success' | 'error' | 'missing-token'; MatButtonModule, MatCardModule, MatIconModule, - MatProgressSpinnerModule, ], templateUrl: './verify-email.component.html', styleUrl: '../auth-page.scss', }) export class VerifyEmailComponent implements OnInit { private readonly auth = inject(AuthService); - private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - protected readonly state = signal('loading'); - protected readonly message = signal('E-Mail wird bestätigt.'); + protected readonly message = signal('E-Mail-Verifikation ist nicht mehr erforderlich.'); protected readonly email = signal(null); ngOnInit(): void { @@ -35,25 +28,5 @@ export class VerifyEmailComponent implements OnInit { void this.router.navigateByUrl('/lists'); return; } - - const token = this.route.snapshot.queryParamMap.get('token'); - - if (!token) { - this.state.set('missing-token'); - this.message.set('Der Verifikationslink enthält keinen Token.'); - return; - } - - this.auth.verifyEmail(token).subscribe({ - next: (response) => { - this.email.set(response.user.email); - this.message.set(response.message); - this.state.set('success'); - }, - error: (error: unknown) => { - this.message.set(getAuthErrorMessage(error)); - this.state.set('error'); - }, - }); } }