SSO implementiert

This commit is contained in:
Bastian Wagner
2026-07-14 17:32:48 +02:00
parent e4d4e78d74
commit f45583f3ea
36 changed files with 896 additions and 653 deletions

View File

@@ -18,6 +18,12 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
# Browser-URL, unter der der Container erreichbar ist. # Browser-URL, unter der der Container erreichbar ist.
CLIENT_URL=http://localhost:8080 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_API_KEY=
MISTRAL_AGENT_ID= MISTRAL_AGENT_ID=

View File

@@ -15,6 +15,12 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
CLIENT_URL=http://localhost:4200 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= MCP_ACCESS_TOKEN=
MISTRAL_API_KEY= MISTRAL_API_KEY=

View File

@@ -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. 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=<keycloak-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/<realm>/.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 ## Run tests
```bash ```bash

View File

@@ -22,6 +22,7 @@
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"handlebars": "^4.7.9", "handlebars": "^4.7.9",
"helmet": "^8.2.0", "helmet": "^8.2.0",
"jose": "^6.2.3",
"mysql2": "^3.22.5", "mysql2": "^3.22.5",
"nodemailer": "^8.0.10", "nodemailer": "^8.0.10",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
@@ -4563,7 +4564,7 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"devOptional": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"normalize-path": "^3.0.0", "normalize-path": "^3.0.0",
@@ -4577,7 +4578,7 @@
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
@@ -4894,7 +4895,7 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fill-range": "^7.1.1" "fill-range": "^7.1.1"
@@ -6955,7 +6956,7 @@
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
@@ -7223,6 +7224,7 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -8006,7 +8008,7 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -8035,7 +8037,7 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
@@ -8065,7 +8067,7 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.12.0" "node": ">=0.12.0"
@@ -10646,7 +10648,7 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -13411,7 +13413,7 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-number": "^7.0.0" "is-number": "^7.0.0"

View File

@@ -37,6 +37,7 @@
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"handlebars": "^4.7.9", "handlebars": "^4.7.9",
"helmet": "^8.2.0", "helmet": "^8.2.0",
"jose": "^6.2.3",
"mysql2": "^3.22.5", "mysql2": "^3.22.5",
"nodemailer": "^8.0.10", "nodemailer": "^8.0.10",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",

View File

@@ -52,8 +52,8 @@ import { DatabaseLogger } from './database/database.logger';
database: configService.get<string>('DB_DATABASE', 'listify'), database: configService.get<string>('DB_DATABASE', 'listify'),
autoLoadEntities: true, autoLoadEntities: true,
synchronize: true, synchronize: true,
logging: parseDatabaseLogging(env.DB_LOGGING), // logging: parseDatabaseLogging(env.DB_LOGGING),
logger: new DatabaseLogger(databaseLoggerOptionsFromEnv(env)), // logger: new DatabaseLogger(databaseLoggerOptionsFromEnv(env)),
maxQueryExecutionTime: slowQueryThresholdFromEnv(env), maxQueryExecutionTime: slowQueryThresholdFromEnv(env),
}; };
}, },

View File

@@ -1,9 +1,5 @@
export type AuditAction = export type AuditAction =
| 'user.registered'
| 'user.email_verified'
| 'user.verification_resent'
| 'user.login_succeeded' | 'user.login_succeeded'
| 'user.login_failed'
| 'user.token_refreshed' | 'user.token_refreshed'
| 'user.onboarding_updated' | 'user.onboarding_updated'
| 'user.task_digest_updated' | 'user.task_digest_updated'

View File

@@ -8,8 +8,10 @@ import {
Post, Post,
Query, Query,
Req, Req,
Res,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response } from 'express';
import type { AuthenticatedRequest } from './auth.types'; import type { AuthenticatedRequest } from './auth.types';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
@@ -44,6 +46,35 @@ export class AuthController {
return this.authService.login(loginDto); 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') @Post('refresh')
refresh(@Body() refreshTokenDto: RefreshTokenDto) { refresh(@Body() refreshTokenDto: RefreshTokenDto) {
return this.authService.refresh(refreshTokenDto); return this.authService.refresh(refreshTokenDto);

View File

@@ -7,6 +7,7 @@ import { RefreshTokenEntity } from './refresh-token.entity';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard';
import { McpAuthGuard } from './mcp-auth.guard'; import { McpAuthGuard } from './mcp-auth.guard';
import { OidcService } from './oidc.service';
import { UserEntity } from './user.entity'; import { UserEntity } from './user.entity';
@Module({ @Module({
@@ -16,7 +17,7 @@ import { UserEntity } from './user.entity';
TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]), TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]),
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, JwtAuthGuard, McpAuthGuard], providers: [AuthService, OidcService, JwtAuthGuard, McpAuthGuard],
exports: [AuthService, JwtAuthGuard, McpAuthGuard], exports: [AuthService, JwtAuthGuard, McpAuthGuard],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -4,27 +4,40 @@ import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from '@nestjs/typeorm';
import { AuthTokenResponse, JwtTokenPayload } from './auth.types'; import { AuthTokenResponse, JwtTokenPayload } from './auth.types';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { MailModule } from '../mail/mail.module'; import { OidcProfile, OidcService } from './oidc.service';
import { MailService } from '../mail/mail.service';
import { RefreshTokenEntity } from './refresh-token.entity'; import { RefreshTokenEntity } from './refresh-token.entity';
import { UserEntity } from './user.entity'; import { UserEntity } from './user.entity';
import { InMemoryRepository } from '../testing/in-memory-repository'; 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', () => { describe('AuthService', () => {
let module: TestingModule; let module: TestingModule;
let authService: AuthService; let authService: AuthService;
let mailService: MailService;
let jwtService: JwtService; let jwtService: JwtService;
let oidcService: FakeOidcService;
beforeEach(async () => { beforeEach(async () => {
oidcService = new FakeOidcService();
module = await Test.createTestingModule({ module = await Test.createTestingModule({
imports: [ imports: [EventEmitterModule.forRoot(), JwtModule.register({})],
EventEmitterModule.forRoot(),
JwtModule.register({}),
MailModule,
],
providers: [ providers: [
AuthService, AuthService,
{
provide: OidcService,
useValue: oidcService,
},
{ {
provide: getRepositoryToken(UserEntity), provide: getRepositoryToken(UserEntity),
useValue: new InMemoryRepository<UserEntity>(), useValue: new InMemoryRepository<UserEntity>(),
@@ -38,7 +51,6 @@ describe('AuthService', () => {
await module.init(); await module.init();
authService = module.get<AuthService>(AuthService); authService = module.get<AuthService>(AuthService);
mailService = module.get<MailService>(MailService);
jwtService = module.get<JwtService>(JwtService); jwtService = module.get<JwtService>(JwtService);
}); });
@@ -46,50 +58,55 @@ describe('AuthService', () => {
await module.close(); await module.close();
}); });
it('registers a user and sends a verification email', async () => { it('starts the SSO login flow', async () => {
const response = await authService.register({ 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', email: 'User@Example.com',
name: 'Test User', name: 'Linked User',
password: 'password123', };
});
const sentEmails = mailService.getSentEmails(); const secondLogin = await authService.completeSsoLogin('code', 'state');
expect(response.user.email).toBe('user@example.com'); expect(secondLogin.user.id).toBe(firstLogin.user.id);
expect(response.user.verified).toBe(false); expect(secondLogin.user.email).toBe('user@example.com');
expect(sentEmails).toHaveLength(1); expect(secondLogin.user.name).toBe('Linked User');
expect(sentEmails[0].to).toBe('user@example.com');
expect(sentEmails[0].verificationUrl).toContain('/verify-email?token=');
}); });
it('rejects login before email verification', async () => { it('issues SSO-backed JWTs on login', async () => {
await authService.register({ const loginResponse = await authService.completeSsoLogin('code', 'state');
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',
});
const accessPayload = jwtService.verify<JwtTokenPayload>( const accessPayload = jwtService.verify<JwtTokenPayload>(
loginResponse.accessToken, loginResponse.accessToken,
{ {
@@ -103,7 +120,6 @@ describe('AuthService', () => {
}, },
); );
expect(verifyResponse.user.verified).toBe(true);
expect(loginResponse.accessToken).toBeDefined(); expect(loginResponse.accessToken).toBeDefined();
expect(loginResponse.refreshToken).toBeDefined(); expect(loginResponse.refreshToken).toBeDefined();
expect(loginResponse.user.email).toBe('user@example.com'); expect(loginResponse.user.email).toBe('user@example.com');
@@ -114,19 +130,7 @@ describe('AuthService', () => {
}); });
it('rotates refresh tokens and rejects reuse', async () => { it('rotates refresh tokens and rejects reuse', async () => {
await authService.register({ const loginResponse = await loginWithSso();
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 refreshResponse = await authService.refresh({ const refreshResponse = await authService.refresh({
refreshToken: loginResponse.refreshToken, refreshToken: loginResponse.refreshToken,
}); });
@@ -140,19 +144,7 @@ describe('AuthService', () => {
}); });
it('rejects access tokens on the refresh endpoint', async () => { it('rejects access tokens on the refresh endpoint', async () => {
await authService.register({ const loginResponse = await loginWithSso();
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',
});
await expect( await expect(
authService.refresh({ refreshToken: loginResponse.accessToken }), authService.refresh({ refreshToken: loginResponse.accessToken }),
@@ -160,19 +152,7 @@ describe('AuthService', () => {
}); });
it('validates access tokens', async () => { it('validates access tokens', async () => {
await authService.register({ const loginResponse = await loginWithSso();
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 payload = await authService.verifyAccessToken( const payload = await authService.verifyAccessToken(
loginResponse.accessToken, loginResponse.accessToken,
); );
@@ -184,33 +164,23 @@ describe('AuthService', () => {
).rejects.toThrow('Access token is invalid.'); ).rejects.toThrow('Access token is invalid.');
}); });
it('rejects duplicate registrations', async () => { it('rejects password registration and login endpoints', async () => {
await authService.register({
email: 'user@example.com',
password: 'password123',
});
await expect( await expect(
authService.register({ authService.register({
email: 'user@example.com', email: 'user@example.com',
password: 'password123', 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<AuthTokenResponse> { async function loginWithSso(): Promise<AuthTokenResponse> {
await authService.register({ return authService.completeSsoLogin('code', 'state');
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',
});
} }
}); });

View File

@@ -1,11 +1,10 @@
import { import {
BadRequestException, BadRequestException,
ConflictException, GoneException,
Injectable, Injectable,
Optional, Optional,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto'; import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto';
@@ -22,8 +21,8 @@ import {
PublicUser, PublicUser,
PublicUserSearchResult, PublicUserSearchResult,
} from './auth.types'; } from './auth.types';
import { AppEvents } from '../events/app-events';
import type { TaskDigestPreference } from '../tasks/task-digest.types'; import type { TaskDigestPreference } from '../tasks/task-digest.types';
import { OidcProfile, OidcService } from './oidc.service';
import { RefreshTokenEntity } from './refresh-token.entity'; import { RefreshTokenEntity } from './refresh-token.entity';
import { UserEntity } from './user.entity'; import { UserEntity } from './user.entity';
@@ -37,8 +36,8 @@ export class AuthService {
process.env.JWT_REFRESH_SECRET ?? 'dev-refresh-secret'; process.env.JWT_REFRESH_SECRET ?? 'dev-refresh-secret';
constructor( constructor(
private readonly eventEmitter: EventEmitter2,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly oidcService: OidcService,
@InjectRepository(UserEntity) @InjectRepository(UserEntity)
private readonly usersRepository: Repository<UserEntity>, private readonly usersRepository: Repository<UserEntity>,
@InjectRepository(RefreshTokenEntity) @InjectRepository(RefreshTokenEntity)
@@ -50,145 +49,46 @@ export class AuthService {
async register( async register(
registerDto: RegisterDto, registerDto: RegisterDto,
): Promise<{ message: string; user: PublicUser }> { ): Promise<{ message: string; user: PublicUser }> {
const email = this.normalizeEmail(registerDto.email); void registerDto;
const password = this.requirePassword(registerDto.password); throw new GoneException('Registration is handled by the SSO provider.');
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),
};
} }
async verifyEmail( async verifyEmail(
token?: string, token?: string,
): Promise<{ message: string; user: PublicUser }> { ): Promise<{ message: string; user: PublicUser }> {
if (!token) { void token;
throw new BadRequestException('Verification token is required.'); throw new GoneException('Email verification is no longer 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.');
}
} }
async resendVerificationEmail( async resendVerificationEmail(
resendVerificationDto: ResendVerificationDto, resendVerificationDto: ResendVerificationDto,
): Promise<{ message: string }> { ): Promise<{ message: string }> {
const email = this.normalizeEmail(resendVerificationDto.email); void resendVerificationDto;
const message = throw new GoneException('Email verification is no longer required.');
'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 };
} }
async login(loginDto: LoginDto): Promise<AuthTokenResponse> { async login(loginDto: LoginDto): Promise<AuthTokenResponse> {
const email = this.normalizeEmail(loginDto.email); void loginDto;
const password = this.requirePassword(loginDto.password); throw new GoneException('Login is handled by SSO.');
const user = await this.usersRepository.findOne({ where: { email } }); }
if (!user || !this.passwordMatches(password, user.passwordHash)) { startSsoLogin(): Promise<string> {
await this.auditLogService?.record({ return this.oidcService.createAuthorizationUrl();
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.');
}
async completeSsoLogin(
code?: string,
state?: string,
): Promise<AuthTokenResponse> {
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 = { const response = {
...(await this.createAuthTokens(user)), ...(await this.createAuthTokens(user)),
user: this.toPublicUser(user), user: this.toPublicUser(user),
@@ -200,6 +100,7 @@ export class AuthService {
action: 'user.login_succeeded', action: 'user.login_succeeded',
entityType: 'user', entityType: 'user',
entityId: user.id, entityId: user.id,
metadata: { directory: 'oidc', oidcSubject: user.oidcSubject },
}); });
return response; return response;
@@ -227,7 +128,7 @@ export class AuthService {
where: { id: payload.sub }, where: { id: payload.sub },
}); });
if (!user || !user.verified) { if (!user) {
throw new UnauthorizedException('Refresh token is invalid.'); throw new UnauthorizedException('Refresh token is invalid.');
} }
@@ -263,7 +164,7 @@ export class AuthService {
where: { id: payload.sub }, where: { id: payload.sub },
}); });
if (!user || !user.verified) { if (!user) {
throw new UnauthorizedException('Access token is invalid.'); throw new UnauthorizedException('Access token is invalid.');
} }
@@ -309,10 +210,7 @@ export class AuthService {
const pattern = `%${normalizedQuery}%`; const pattern = `%${normalizedQuery}%`;
const users = await this.usersRepository.find({ const users = await this.usersRepository.find({
where: [ where: [{ email: Like(pattern) }, { name: Like(pattern) }],
{ verified: true, email: Like(pattern) },
{ verified: true, name: Like(pattern) },
],
order: { email: 'ASC' }, order: { email: 'ASC' },
take: 10, take: 10,
}); });
@@ -414,30 +312,35 @@ export class AuthService {
throw new BadRequestException('Task digest preference is invalid.'); throw new BadRequestException('Task digest preference is invalid.');
} }
private requirePassword(password?: string): string { private async syncOidcUser(
if (!password || password.length < 8) { profile: OidcProfile,
throw new BadRequestException( existingUser?: UserEntity | null,
'Password must contain at least 8 characters.', ): Promise<UserEntity> {
); 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 { private secretMatches(secret: string, storedSecretHash: string): boolean {
const salt = randomBytes(16).toString('hex'); const [salt, storedHash] = storedSecretHash.split(':');
const hash = scryptSync(password, salt, 64).toString('hex');
return `${salt}:${hash}`;
}
private passwordMatches(password: string, passwordHash: string): boolean {
const [salt, storedHash] = passwordHash.split(':');
if (!salt || !storedHash) { if (!salt || !storedHash) {
return false; return false;
} }
const attemptedHash = scryptSync(password, salt, 64); const attemptedHash = scryptSync(secret, salt, 64);
const storedHashBuffer = Buffer.from(storedHash, 'hex'); const storedHashBuffer = Buffer.from(storedHash, 'hex');
return ( return (
@@ -513,16 +416,7 @@ export class AuthService {
} }
private tokenMatches(token: string, tokenHash: string): boolean { private tokenMatches(token: string, tokenHash: string): boolean {
return this.passwordMatches(token, tokenHash); return this.secretMatches(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}`;
} }
private toPublicUser(user: UserEntity): PublicUser { private toPublicUser(user: UserEntity): PublicUser {
@@ -530,7 +424,6 @@ export class AuthService {
id: user.id, id: user.id,
email: user.email, email: user.email,
name: user.name ?? undefined, name: user.name ?? undefined,
verified: user.verified,
onboardingCompleted: user.onboardingCompleted === true, onboardingCompleted: user.onboardingCompleted === true,
taskDigestPreference: user.taskDigestPreference ?? 'both', taskDigestPreference: user.taskDigestPreference ?? 'both',
}; };

View File

@@ -1,17 +1,6 @@
import { Request } from 'express'; import { Request } from 'express';
import type { TaskDigestPreference } from '../tasks/task-digest.types'; 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 { export interface AuthTokens {
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
@@ -36,7 +25,6 @@ export interface PublicUser {
id: string; id: string;
email: string; email: string;
name?: string; name?: string;
verified: boolean;
onboardingCompleted: boolean; onboardingCompleted: boolean;
taskDigestPreference: TaskDigestPreference; taskDigestPreference: TaskDigestPreference;
} }

View File

@@ -6,8 +6,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthenticatedRequest } from './auth.types'; import { AuthenticatedRequest } from './auth.types';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard';
import { MailModule } from '../mail/mail.module'; import { OidcService } from './oidc.service';
import { MailService } from '../mail/mail.service';
import { RefreshTokenEntity } from './refresh-token.entity'; import { RefreshTokenEntity } from './refresh-token.entity';
import { UserEntity } from './user.entity'; import { UserEntity } from './user.entity';
import { InMemoryRepository } from '../testing/in-memory-repository'; import { InMemoryRepository } from '../testing/in-memory-repository';
@@ -16,14 +15,26 @@ describe('JwtAuthGuard', () => {
let module: TestingModule; let module: TestingModule;
let authService: AuthService; let authService: AuthService;
let guard: JwtAuthGuard; let guard: JwtAuthGuard;
let mailService: MailService;
beforeEach(async () => { beforeEach(async () => {
module = await Test.createTestingModule({ module = await Test.createTestingModule({
imports: [EventEmitterModule.forRoot(), JwtModule.register({}), MailModule], imports: [EventEmitterModule.forRoot(), JwtModule.register({})],
providers: [ providers: [
AuthService, AuthService,
JwtAuthGuard, 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), provide: getRepositoryToken(UserEntity),
useValue: new InMemoryRepository<UserEntity>(), useValue: new InMemoryRepository<UserEntity>(),
@@ -38,7 +49,6 @@ describe('JwtAuthGuard', () => {
authService = module.get<AuthService>(AuthService); authService = module.get<AuthService>(AuthService);
guard = module.get<JwtAuthGuard>(JwtAuthGuard); guard = module.get<JwtAuthGuard>(JwtAuthGuard);
mailService = module.get<MailService>(MailService);
}); });
afterEach(async () => { afterEach(async () => {
@@ -78,19 +88,7 @@ describe('JwtAuthGuard', () => {
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
}> { }> {
await authService.register({ return authService.completeSsoLogin('code', 'state');
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',
});
} }
function createRequest( function createRequest(

View File

@@ -0,0 +1,233 @@
import {
BadRequestException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { createHash, randomBytes } from 'crypto';
type JoseModule = typeof import('jose');
type RemoteJwkSet = ReturnType<JoseModule['createRemoteJWKSet']>;
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<string, PendingOidcState>();
private discovery?: OidcDiscovery;
private jose?: Promise<JoseModule>;
private jwks?: RemoteJwkSet;
async createAuthorizationUrl(): Promise<string> {
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<OidcProfile> {
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<OidcService['getConfig']>,
code: string,
codeVerifier: string,
): Promise<TokenResponse> {
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<OidcDiscovery> {
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<RemoteJwkSet> {
const { createRemoteJWKSet } = await this.getJose();
this.jwks ??= createRemoteJWKSet(new URL(jwksUri));
return this.jwks;
}
private getJose(): Promise<JoseModule> {
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);
}
}
}
}

View File

@@ -25,19 +25,13 @@ export class UserEntity {
@Column({ type: 'varchar', length: 320 }) @Column({ type: 'varchar', length: 320 })
email!: string; email!: string;
@Index({ unique: true })
@Column({ type: 'varchar', length: 255, nullable: true, select: false })
oidcSubject?: string | null;
@Column({ type: 'varchar', length: 160, nullable: true }) @Column({ type: 'varchar', length: 160, nullable: true })
name?: string | null; 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 }) @Column({ type: 'boolean', default: false })
onboardingCompleted!: boolean; onboardingCompleted!: boolean;
@@ -51,7 +45,7 @@ export class UserEntity {
taskDigestAfternoonProcessedDate?: string | null; taskDigestAfternoonProcessedDate?: string | null;
@Index('IDX_users_mcp_api_key_hash', { unique: true }) @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; mcpApiKeyHash?: string | null;
@Column({ type: 'datetime', precision: 3, nullable: true }) @Column({ type: 'datetime', precision: 3, nullable: true })

View File

@@ -0,0 +1,87 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddSsoSubjectToUsers1782200000000 implements MigrationInterface {
name = 'AddSsoSubjectToUsers1782200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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<void> {
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<void> {
if (!(await queryRunner.hasColumn('users', columnName))) {
await queryRunner.query(query);
}
}
private async dropIndexIfExists(
queryRunner: QueryRunner,
indexName: string,
): Promise<void> {
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\``);
}
}
}

View File

@@ -92,8 +92,6 @@ describe('ListTemplatesService', () => {
await usersRepository.save({ await usersRepository.save({
id: 'user-2', id: 'user-2',
email: 'collaborator@example.com', email: 'collaborator@example.com',
passwordHash: 'hash',
verified: true,
onboardingCompleted: false, onboardingCompleted: false,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),

View File

@@ -217,7 +217,7 @@ export class ListTemplatesService {
where: { id: targetUserId }, where: { id: targetUserId },
}); });
if (!targetUser || !targetUser.verified) { if (!targetUser) {
throw new NotFoundException('User was not found.'); throw new NotFoundException('User was not found.');
} }

View File

@@ -179,8 +179,6 @@ describe('ListsService', () => {
await usersRepository.save({ await usersRepository.save({
id: 'user-2', id: 'user-2',
email: 'collaborator@example.com', email: 'collaborator@example.com',
passwordHash: 'hash',
verified: true,
onboardingCompleted: false, onboardingCompleted: false,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
@@ -351,8 +349,6 @@ describe('ListsService', () => {
await usersRepository.save({ await usersRepository.save({
id: 'user-2', id: 'user-2',
email: 'collaborator@example.com', email: 'collaborator@example.com',
passwordHash: 'hash',
verified: true,
onboardingCompleted: false, onboardingCompleted: false,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),

View File

@@ -380,7 +380,7 @@ export class ListsService {
where: { id: targetUserId }, where: { id: targetUserId },
}); });
if (!targetUser || !targetUser.verified) { if (!targetUser) {
throw new NotFoundException('User was not found.'); throw new NotFoundException('User was not found.');
} }

View File

@@ -226,8 +226,6 @@ describe('TaskDigestService', () => {
id: overrides.id ?? 'owner-1', id: overrides.id ?? 'owner-1',
email: overrides.email ?? 'owner@example.com', email: overrides.email ?? 'owner@example.com',
name: overrides.name ?? 'Owner', name: overrides.name ?? 'Owner',
passwordHash: 'hash',
verified: overrides.verified ?? true,
onboardingCompleted: false, onboardingCompleted: false,
taskDigestPreference: overrides.taskDigestPreference ?? 'both', taskDigestPreference: overrides.taskDigestPreference ?? 'both',
taskDigestMorningProcessedDate: taskDigestMorningProcessedDate:

View File

@@ -75,7 +75,6 @@ export class TaskDigestService {
try { try {
const dateKey = this.dateKey(now); const dateKey = this.dateKey(now);
const users = await this.usersRepository.find({ const users = await this.usersRepository.find({
where: { verified: true },
order: { email: 'ASC' }, order: { email: 'ASC' },
}); });

View File

@@ -2,8 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import request from 'supertest'; import request from 'supertest';
import { App } from 'supertest/types'; import { App } from 'supertest/types';
import { DataSource } from 'typeorm';
import { AppModule } from './../src/app.module'; import { AppModule } from './../src/app.module';
import { MailService } from './../src/mail/mail.service'; import { OidcProfile, OidcService } from '../src/auth/oidc.service';
interface AuthResponseBody { interface AuthResponseBody {
accessToken?: string; accessToken?: string;
@@ -11,7 +12,6 @@ interface AuthResponseBody {
user: { user: {
id?: string; id?: string;
email: string; email: string;
verified: boolean;
}; };
} }
@@ -28,16 +28,36 @@ interface ListTemplateResponseBody {
describe('AppController (e2e)', () => { describe('AppController (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
let mailService: MailService; let oidcService: {
createAuthorizationUrl: jest.Mock<Promise<string>, []>;
exchangeCallback: jest.Mock<
Promise<OidcProfile>,
[string | undefined, string | undefined]
>;
};
beforeEach(async () => { 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({ const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule], imports: [AppModule],
}).compile(); })
.overrideProvider(OidcService)
.useValue(oidcService)
.compile();
mailService = moduleFixture.get<MailService>(MailService);
app = moduleFixture.createNestApplication(); app = moduleFixture.createNestApplication();
await app.init(); await app.init();
await ensureSsoSchema(app.get(DataSource));
}); });
it('/ (GET)', () => { it('/ (GET)', () => {
@@ -47,50 +67,13 @@ describe('AppController (e2e)', () => {
.expect('Hello World!'); .expect('Hello World!');
}); });
it('/auth register, verify and login', async () => { it('/auth sso callback and refresh', async () => {
const registerResponse = await request(app.getHttpServer()) const email = uniqueEmail('auth-user');
.post('/auth/register') const loginBody = await loginWithSso(email);
.send({
email: 'user@example.com',
password: 'password123',
})
.expect(201);
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.accessToken).toBeDefined();
expect(loginBody.refreshToken).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()) const refreshResponse = await request(app.getHttpServer())
.post('/auth/refresh') .post('/auth/refresh')
@@ -113,8 +96,8 @@ describe('AppController (e2e)', () => {
}); });
it('/list-templates creates, updates and uses a template', async () => { it('/list-templates creates, updates and uses a template', async () => {
const accessToken = await registerVerifiedUserAndGetAccessToken( const accessToken = await loginWithSsoAndGetAccessToken(
'template-user@example.com', uniqueEmail('template-user'),
); );
const initialTemplatesResponse = await request(app.getHttpServer()) const initialTemplatesResponse = await request(app.getHttpServer())
@@ -172,8 +155,8 @@ describe('AppController (e2e)', () => {
}); });
it('/lists creates, updates and reads a concrete list', async () => { it('/lists creates, updates and reads a concrete list', async () => {
const accessToken = await registerVerifiedUserAndGetAccessToken( const accessToken = await loginWithSsoAndGetAccessToken(
'list-user@example.com', uniqueEmail('list-user'),
); );
const createListResponse = await request(app.getHttpServer()) const createListResponse = await request(app.getHttpServer())
@@ -222,41 +205,75 @@ describe('AppController (e2e)', () => {
expect(fetchedList.items[0].checked).toBe(true); expect(fetchedList.items[0].checked).toBe(true);
}); });
async function registerVerifiedUserAndGetAccessToken( async function loginWithSsoAndGetAccessToken(
email: string, email: string,
): Promise<string> { ): Promise<string> {
await request(app.getHttpServer()) const loginBody = await loginWithSso(email);
.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;
expect(loginBody.accessToken).toBeDefined(); expect(loginBody.accessToken).toBeDefined();
return loginBody.accessToken ?? ''; return loginBody.accessToken ?? '';
} }
async function loginWithSso(email: string): Promise<AuthResponseBody> {
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<void> {
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<void> {
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 () => { afterEach(async () => {
await app.close(); await app.close();
}); });

View File

@@ -8,7 +8,7 @@
<mat-card-content> <mat-card-content>
<div class="status-row"> <div class="status-row">
<mat-icon aria-hidden="true">verified_user</mat-icon> <mat-icon aria-hidden="true">verified_user</mat-icon>
<span>{{ auth.user()?.verified ? 'E-Mail verifiziert' : 'E-Mail nicht verifiziert' }}</span> <span>SSO-Konto aktiv</span>
</div> </div>
<div class="status-row"> <div class="status-row">

View File

@@ -41,15 +41,6 @@
<mat-icon aria-hidden="true">login</mat-icon> <mat-icon aria-hidden="true">login</mat-icon>
Login Login
</a> </a>
<a
mat-flat-button
routerLink="/register"
routerLinkActive="active-link"
ariaCurrentWhenActive="page"
>
<mat-icon aria-hidden="true">person_add</mat-icon>
Registrieren
</a>
} }
</mat-toolbar> </mat-toolbar>

View File

@@ -2,8 +2,7 @@ import { Routes } from '@angular/router';
import { authGuard } from './auth/auth.guard'; import { authGuard } from './auth/auth.guard';
import { unauthGuard } from './auth/unauth.guard'; import { unauthGuard } from './auth/unauth.guard';
import { LoginComponent } from './auth/login/login.component'; import { LoginComponent } from './auth/login/login.component';
import { RegisterComponent } from './auth/register/register.component'; import { SsoCallbackComponent } from './auth/sso-callback/sso-callback.component';
import { VerifyEmailComponent } from './auth/verify-email/verify-email.component';
import { ListDetailComponent } from './lists/list-detail/list-detail.component'; import { ListDetailComponent } from './lists/list-detail/list-detail.component';
import { ListsComponent } from './lists/lists.component'; import { ListsComponent } from './lists/lists.component';
import { TemplatesComponent } from './templates/templates.component'; import { TemplatesComponent } from './templates/templates.component';
@@ -12,12 +11,7 @@ import { TemplateDetailComponent } from './templates/template-detail/template-de
export const routes: Routes = [ export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' }, { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
{ path: 'login', component: LoginComponent, canActivate: [unauthGuard] }, { path: 'login', component: LoginComponent, canActivate: [unauthGuard] },
{ path: 'register', component: RegisterComponent, canActivate: [unauthGuard] }, { path: 'auth/sso/callback', component: SsoCallbackComponent },
{ path: 'verify-email', component: VerifyEmailComponent, canActivate: [unauthGuard] },
{
path: 'auth',
children: [{ path: 'verify-email', component: VerifyEmailComponent }],
},
{ {
path: 'dashboard', path: 'dashboard',
loadComponent: () => loadComponent: () =>

View File

@@ -9,8 +9,16 @@
align-items: start; align-items: start;
padding: 1.25rem; padding: 1.25rem;
background: background:
linear-gradient(140deg, color-mix(in srgb, var(--mat-sys-primary) 14%, transparent), transparent 38%), linear-gradient(
linear-gradient(320deg, color-mix(in srgb, var(--mat-sys-tertiary) 12%, transparent), transparent 36%), 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); var(--mat-sys-surface-container);
} }
@@ -21,7 +29,9 @@
border-radius: 8px; border-radius: 8px;
background: var(--mat-sys-surface); background: var(--mat-sys-surface);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); 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 { .auth-card:hover {
@@ -54,7 +64,11 @@
border: 1px solid color-mix(in srgb, var(--mat-sys-primary) 16%, transparent); border: 1px solid color-mix(in srgb, var(--mat-sys-primary) 16%, transparent);
border-radius: 8px; border-radius: 8px;
background: 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); var(--mat-sys-surface-container-low);
color: var(--mat-sys-primary); color: var(--mat-sys-primary);
} }
@@ -101,6 +115,18 @@
margin-right: 0.5rem; 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 { .auth-card mat-card-actions {
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;

View File

@@ -25,9 +25,7 @@ export const authInterceptor: HttpInterceptorFn = (
} }
return auth.refreshSession().pipe( return auth.refreshSession().pipe(
switchMap((response) => switchMap((response) => next(withAccessToken(request, response.accessToken))),
next(withAccessToken(request, response.accessToken)),
),
catchError((refreshError: unknown) => { catchError((refreshError: unknown) => {
auth.logout(); auth.logout();
void router.navigateByUrl('/login'); void router.navigateByUrl('/login');
@@ -70,6 +68,7 @@ function isAuthRequest(request: HttpRequest<unknown>): boolean {
return [ return [
'/api/auth/login', '/api/auth/login',
'/api/auth/register', '/api/auth/register',
'/api/auth/sso',
'/api/auth/refresh', '/api/auth/refresh',
'/api/auth/resend-verification', '/api/auth/resend-verification',
'/api/auth/verify-email', '/api/auth/verify-email',

View File

@@ -4,7 +4,6 @@ export interface PublicUser {
id: string; id: string;
email: string; email: string;
name?: string; name?: string;
verified: boolean;
onboardingCompleted: boolean; onboardingCompleted: boolean;
taskDigestPreference: TaskDigestPreference; taskDigestPreference: TaskDigestPreference;
} }
@@ -26,15 +25,6 @@ export interface RegisterResponse {
user: PublicUser; user: PublicUser;
} }
export interface VerifyEmailResponse {
message: string;
user: PublicUser;
}
export interface ResendVerificationResponse {
message: string;
}
export interface LoginRequest { export interface LoginRequest {
email: string; email: string;
password: string; password: string;

View File

@@ -8,9 +8,7 @@ import {
PublicUserSearchResult, PublicUserSearchResult,
RegisterRequest, RegisterRequest,
RegisterResponse, RegisterResponse,
ResendVerificationResponse,
TaskDigestPreference, TaskDigestPreference,
VerifyEmailResponse,
} from './auth.models'; } from './auth.models';
const ACCESS_TOKEN_KEY = 'listify.accessToken'; const ACCESS_TOKEN_KEY = 'listify.accessToken';
@@ -33,21 +31,26 @@ export class AuthService {
.pipe(tap((response) => this.storeSession(response))); .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<AuthTokenResponse> {
return this.http
.post<AuthTokenResponse>(`${this.apiUrl}/sso/exchange`, { code, state })
.pipe(tap((response) => this.storeSession(response)));
}
register(data: RegisterRequest): Observable<RegisterResponse> { register(data: RegisterRequest): Observable<RegisterResponse> {
return this.http.post<RegisterResponse>(`${this.apiUrl}/register`, data); return this.http.post<RegisterResponse>(`${this.apiUrl}/register`, data);
} }
verifyEmail(token: string): Observable<VerifyEmailResponse> {
const params = new HttpParams().set('token', token);
return this.http.get<VerifyEmailResponse>(`${this.apiUrl}/verify-email`, { params });
}
resendVerificationEmail(email: string): Observable<ResendVerificationResponse> {
return this.http.post<ResendVerificationResponse>(`${this.apiUrl}/resend-verification`, {
email,
});
}
loadCurrentUser(): Observable<PublicUser> { loadCurrentUser(): Observable<PublicUser> {
return this.http.get<PublicUser>(`${this.apiUrl}/me`).pipe(tap((user) => this.storeUser(user))); return this.http.get<PublicUser>(`${this.apiUrl}/me`).pipe(tap((user) => this.storeUser(user)));
} }

View File

@@ -5,81 +5,25 @@
</div> </div>
<mat-card-header> <mat-card-header>
<mat-card-title>Willkommen zurueck</mat-card-title> <mat-card-title>Willkommen zurueck</mat-card-title>
<mat-card-subtitle>Melden Sie sich mit Ihrem Listify-Konto an</mat-card-subtitle> <mat-card-subtitle>Melden Sie sich mit Ihrem SSO-Konto an</mat-card-subtitle>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<form [formGroup]="form" (ngSubmit)="submit()" class="auth-form"> <button
<mat-form-field appearance="outline"> mat-flat-button
<mat-label>E-Mail</mat-label> color="primary"
<input matInput type="email" formControlName="email" autocomplete="email" /> type="button"
<mat-icon matSuffix aria-hidden="true">mail</mat-icon> class="sso-login-button"
@if (form.controls.email.hasError('required')) { [disabled]="loading"
<mat-error>E-Mail ist erforderlich</mat-error> (click)="loginWithSso()"
} @else if (form.controls.email.hasError('email')) { >
<mat-error>Bitte geben Sie eine gueltige E-Mail ein</mat-error> @if (loading) {
} <mat-progress-spinner mode="indeterminate" diameter="18" />
</mat-form-field> } @else {
<mat-icon aria-hidden="true">login</mat-icon>
<mat-form-field appearance="outline"> }
<mat-label>Passwort</mat-label> Mit SSO anmelden
<input </button>
matInput
[type]="hidePassword ? 'password' : 'text'"
formControlName="password"
autocomplete="current-password"
/>
<button
mat-icon-button
matSuffix
type="button"
[attr.aria-label]="hidePassword ? 'Passwort anzeigen' : 'Passwort verbergen'"
(click)="hidePassword = !hidePassword"
>
<mat-icon aria-hidden="true">{{ hidePassword ? 'visibility' : 'visibility_off' }}</mat-icon>
</button>
@if (form.controls.password.hasError('required')) {
<mat-error>Passwort ist erforderlich</mat-error>
} @else if (form.controls.password.hasError('minlength')) {
<mat-error>Mindestens 8 Zeichen</mat-error>
}
</mat-form-field>
<button mat-flat-button color="primary" type="submit" [disabled]="loading">
@if (loading) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">login</mat-icon>
}
Einloggen
</button>
<div class="divider-container">
<span class="divider-text">oder</span>
</div>
<button
mat-stroked-button
type="button"
[disabled]="resendingVerification || loading"
(click)="resendVerificationEmail()"
color="accent"
>
@if (resendingVerification) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">mark_email_unread</mat-icon>
}
Verifizierungsmail erneut senden
</button>
</form>
</mat-card-content> </mat-card-content>
<mat-card-actions>
<span>Neu hier?</span>
<a mat-flat-button routerLink="/register" color="primary" class="register-link">
Konto erstellen
</a>
</mat-card-actions>
</mat-card> </mat-card>
</section> </section>

View File

@@ -1,45 +1,21 @@
import { Component, inject, OnInit } from '@angular/core'; import { Component, inject, OnInit } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { Router } from '@angular/router';
import { Router, RouterLink } from '@angular/router';
import { finalize } from 'rxjs';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AuthService } from '../auth.service'; import { AuthService } from '../auth.service';
import { getAuthErrorMessage } from '../error-message';
import { OnboardingService } from '../../onboarding/onboarding.service';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
imports: [ imports: [MatButtonModule, MatCardModule, MatIconModule, MatProgressSpinnerModule],
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSnackBarModule,
],
templateUrl: './login.component.html', templateUrl: './login.component.html',
styleUrl: '../auth-page.scss', styleUrl: '../auth-page.scss',
}) })
export class LoginComponent implements OnInit { export class LoginComponent implements OnInit {
private readonly auth = inject(AuthService); private readonly auth = inject(AuthService);
private readonly formBuilder = inject(NonNullableFormBuilder);
private readonly router = inject(Router); 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; protected loading = false;
ngOnInit(): void { ngOnInit(): void {
@@ -47,51 +23,9 @@ export class LoginComponent implements OnInit {
void this.router.navigateByUrl('/lists'); 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.loading = true;
this.auth this.auth.startSsoLogin();
.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 });
},
});
} }
} }

View File

@@ -0,0 +1,22 @@
<section class="auth-page">
<mat-card class="auth-card">
<div class="auth-logo">
<mat-icon>{{ failed ? 'error' : 'login' }}</mat-icon>
</div>
<mat-card-header>
<mat-card-title>
{{ failed ? 'Anmeldung fehlgeschlagen' : 'Anmeldung wird abgeschlossen' }}
</mat-card-title>
</mat-card-header>
@if (!failed) {
<mat-card-content>
<mat-progress-spinner mode="indeterminate" diameter="32" />
</mat-card-content>
} @else if (errorMessage) {
<mat-card-content>
<p>{{ errorMessage }}</p>
</mat-card-content>
}
</mat-card>
</section>

View File

@@ -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.';
}
}

View File

@@ -9,29 +9,17 @@
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<div class="verification-state" [class.success]="state() === 'success'" [class.error]="state() === 'error' || state() === 'missing-token'"> <div class="verification-state success">
@if (state() === 'loading') { <mat-icon class="state-icon" aria-hidden="true">mark_email_read</mat-icon>
<mat-progress-spinner mode="indeterminate" diameter="44" />
} @else if (state() === 'success') {
<mat-icon class="state-icon" aria-hidden="true">mark_email_read</mat-icon>
} @else {
<mat-icon class="state-icon" aria-hidden="true">error</mat-icon>
}
<p>{{ message() }}</p> <p>{{ message() }}</p>
</div> </div>
</mat-card-content> </mat-card-content>
<mat-card-actions align="end"> <mat-card-actions align="end">
@if (state() === 'success') { <a mat-flat-button routerLink="/login">
<a mat-flat-button routerLink="/login"> <mat-icon aria-hidden="true">login</mat-icon>
<mat-icon aria-hidden="true">login</mat-icon> Zum Login
Zum Login </a>
</a>
} @else if (state() !== 'loading') {
<a mat-button routerLink="/register">Neu registrieren</a>
<a mat-flat-button routerLink="/login">Zum Login</a>
}
</mat-card-actions> </mat-card-actions>
</mat-card> </mat-card>
</section> </section>

View File

@@ -1,13 +1,9 @@
import { Component, OnInit, inject, signal } from '@angular/core'; 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 { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { AuthService } from '../auth.service'; import { AuthService } from '../auth.service';
import { getAuthErrorMessage } from '../error-message';
type VerificationState = 'loading' | 'success' | 'error' | 'missing-token';
@Component({ @Component({
selector: 'app-verify-email', selector: 'app-verify-email',
@@ -16,18 +12,15 @@ type VerificationState = 'loading' | 'success' | 'error' | 'missing-token';
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
MatIconModule, MatIconModule,
MatProgressSpinnerModule,
], ],
templateUrl: './verify-email.component.html', templateUrl: './verify-email.component.html',
styleUrl: '../auth-page.scss', styleUrl: '../auth-page.scss',
}) })
export class VerifyEmailComponent implements OnInit { export class VerifyEmailComponent implements OnInit {
private readonly auth = inject(AuthService); private readonly auth = inject(AuthService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
protected readonly state = signal<VerificationState>('loading'); protected readonly message = signal('E-Mail-Verifikation ist nicht mehr erforderlich.');
protected readonly message = signal('E-Mail wird bestätigt.');
protected readonly email = signal<string | null>(null); protected readonly email = signal<string | null>(null);
ngOnInit(): void { ngOnInit(): void {
@@ -35,25 +28,5 @@ export class VerifyEmailComponent implements OnInit {
void this.router.navigateByUrl('/lists'); void this.router.navigateByUrl('/lists');
return; 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');
},
});
} }
} }