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.
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=

View File

@@ -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=

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.
## 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
```bash

View File

@@ -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"

View File

@@ -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",

View File

@@ -52,8 +52,8 @@ import { DatabaseLogger } from './database/database.logger';
database: configService.get<string>('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),
};
},

View File

@@ -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'

View File

@@ -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);

View File

@@ -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 {}

View File

@@ -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<UserEntity>(),
@@ -38,7 +51,6 @@ describe('AuthService', () => {
await module.init();
authService = module.get<AuthService>(AuthService);
mailService = module.get<MailService>(MailService);
jwtService = module.get<JwtService>(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<JwtTokenPayload>(
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<AuthTokenResponse> {
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<AuthTokenResponse> {
return authService.completeSsoLogin('code', 'state');
}
});

View File

@@ -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<UserEntity>,
@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<AuthTokenResponse> {
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<string> {
return this.oidcService.createAuthorizationUrl();
}
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 = {
...(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<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 {
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',
};

View File

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

View File

@@ -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<UserEntity>(),
@@ -38,7 +49,6 @@ describe('JwtAuthGuard', () => {
authService = module.get<AuthService>(AuthService);
guard = module.get<JwtAuthGuard>(JwtAuthGuard);
mailService = module.get<MailService>(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(

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 })
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 })

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({
id: 'user-2',
email: 'collaborator@example.com',
passwordHash: 'hash',
verified: true,
onboardingCompleted: false,
createdAt: new Date(),
updatedAt: new Date(),

View File

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

View File

@@ -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(),

View File

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

View File

@@ -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:

View File

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

View File

@@ -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<App>;
let mailService: MailService;
let oidcService: {
createAuthorizationUrl: jest.Mock<Promise<string>, []>;
exchangeCallback: jest.Mock<
Promise<OidcProfile>,
[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>(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<string> {
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<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 () => {
await app.close();
});

View File

@@ -8,7 +8,7 @@
<mat-card-content>
<div class="status-row">
<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 class="status-row">

View File

@@ -41,15 +41,6 @@
<mat-icon aria-hidden="true">login</mat-icon>
Login
</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>

View File

@@ -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: () =>

View File

@@ -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;

View File

@@ -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<unknown>): boolean {
return [
'/api/auth/login',
'/api/auth/register',
'/api/auth/sso',
'/api/auth/refresh',
'/api/auth/resend-verification',
'/api/auth/verify-email',

View File

@@ -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;

View File

@@ -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<AuthTokenResponse> {
return this.http
.post<AuthTokenResponse>(`${this.apiUrl}/sso/exchange`, { code, state })
.pipe(tap((response) => this.storeSession(response)));
}
register(data: RegisterRequest): Observable<RegisterResponse> {
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> {
return this.http.get<PublicUser>(`${this.apiUrl}/me`).pipe(tap((user) => this.storeUser(user)));
}

View File

@@ -5,81 +5,25 @@
</div>
<mat-card-header>
<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-content>
<form [formGroup]="form" (ngSubmit)="submit()" class="auth-form">
<mat-form-field appearance="outline">
<mat-label>E-Mail</mat-label>
<input matInput type="email" formControlName="email" autocomplete="email" />
<mat-icon matSuffix aria-hidden="true">mail</mat-icon>
@if (form.controls.email.hasError('required')) {
<mat-error>E-Mail ist erforderlich</mat-error>
} @else if (form.controls.email.hasError('email')) {
<mat-error>Bitte geben Sie eine gueltige E-Mail ein</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Passwort</mat-label>
<input
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>
<button
mat-flat-button
color="primary"
type="button"
class="sso-login-button"
[disabled]="loading"
(click)="loginWithSso()"
>
@if (loading) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">login</mat-icon>
}
Mit SSO anmelden
</button>
</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>
</section>

View File

@@ -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();
}
}

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-content>
<div class="verification-state" [class.success]="state() === 'success'" [class.error]="state() === 'error' || state() === 'missing-token'">
@if (state() === 'loading') {
<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>
}
<div class="verification-state success">
<mat-icon class="state-icon" aria-hidden="true">mark_email_read</mat-icon>
<p>{{ message() }}</p>
</div>
</mat-card-content>
<mat-card-actions align="end">
@if (state() === 'success') {
<a mat-flat-button routerLink="/login">
<mat-icon aria-hidden="true">login</mat-icon>
Zum Login
</a>
} @else if (state() !== 'loading') {
<a mat-button routerLink="/register">Neu registrieren</a>
<a mat-flat-button routerLink="/login">Zum Login</a>
}
<a mat-flat-button routerLink="/login">
<mat-icon aria-hidden="true">login</mat-icon>
Zum Login
</a>
</mat-card-actions>
</mat-card>
</section>

View File

@@ -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<VerificationState>('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<string | null>(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');
},
});
}
}