SSO implementiert
This commit is contained in:
@@ -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=
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
20
listify-api/package-lock.json
generated
20
listify-api/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
233
listify-api/src/auth/oidc.service.ts
Normal file
233
listify-api/src/auth/oidc.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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\``);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -217,7 +217,7 @@ export class ListTemplatesService {
|
||||
where: { id: targetUserId },
|
||||
});
|
||||
|
||||
if (!targetUser || !targetUser.verified) {
|
||||
if (!targetUser) {
|
||||
throw new NotFoundException('User was not found.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -380,7 +380,7 @@ export class ListsService {
|
||||
where: { id: targetUserId },
|
||||
});
|
||||
|
||||
if (!targetUser || !targetUser.verified) {
|
||||
if (!targetUser) {
|
||||
throw new NotFoundException('User was not found.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user