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