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.
|
# Browser-URL, unter der der Container erreichbar ist.
|
||||||
CLIENT_URL=http://localhost:8080
|
CLIENT_URL=http://localhost:8080
|
||||||
|
|
||||||
|
OIDC_ISSUER_URL=https://auth.forgecore.work/realms/listify
|
||||||
|
OIDC_DISCOVERY_URL=
|
||||||
|
OIDC_CLIENT_ID=listify
|
||||||
|
OIDC_CLIENT_SECRET=
|
||||||
|
OIDC_CALLBACK_URL=http://localhost:8080/auth/sso/callback
|
||||||
|
|
||||||
MISTRAL_API_KEY=
|
MISTRAL_API_KEY=
|
||||||
MISTRAL_AGENT_ID=
|
MISTRAL_AGENT_ID=
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
|
|||||||
|
|
||||||
CLIENT_URL=http://localhost:4200
|
CLIENT_URL=http://localhost:4200
|
||||||
|
|
||||||
|
OIDC_ISSUER_URL=https://auth.forgecore.work/realms/Homelab/account
|
||||||
|
OIDC_DISCOVERY_URL=
|
||||||
|
OIDC_CLIENT_ID=listify
|
||||||
|
OIDC_CLIENT_SECRET=
|
||||||
|
OIDC_CALLBACK_URL=http://localhost:4200/auth/sso/callback
|
||||||
|
|
||||||
MCP_ACCESS_TOKEN=
|
MCP_ACCESS_TOKEN=
|
||||||
|
|
||||||
MISTRAL_API_KEY=
|
MISTRAL_API_KEY=
|
||||||
|
|||||||
@@ -60,6 +60,56 @@ Configure the external MCP connector with `Authorization: Bearer $MCP_ACCESS_TOK
|
|||||||
|
|
||||||
Every Mistral response is stored in `assistant_chat_logs`. The table includes the sanitized provider request, the full raw provider response, the extracted assistant text sent back to the UI, response status and timing metadata.
|
Every Mistral response is stored in `assistant_chat_logs`. The table includes the sanitized provider request, the full raw provider response, the extracted assistant text sent back to the UI, response status and timing metadata.
|
||||||
|
|
||||||
|
## SSO mit Keycloak
|
||||||
|
|
||||||
|
Listify nutzt OpenID Connect mit Authorization Code + PKCE. Bei Keycloak muss der Issuer immer auf den Realm zeigen, nicht nur auf die Basisdomain.
|
||||||
|
|
||||||
|
### Keycloak Client
|
||||||
|
|
||||||
|
1. In Keycloak im passenden Realm einen OpenID-Connect-Client fuer Listify anlegen, z. B. `listify`.
|
||||||
|
2. `Standard flow` aktivieren. PKCE mit `S256` erlauben oder erzwingen.
|
||||||
|
3. Scopes `openid`, `email` und `profile` verfuegbar machen.
|
||||||
|
4. Der User muss ein `email` Claim im ID Token erhalten. Ohne E-Mail lehnt Listify den Login ab.
|
||||||
|
5. Redirect URI fuer die Browser-URL eintragen:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:4200/auth/sso/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
Bei Docker/Reverse Proxy:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:8080/auth/sso/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
In Produktion muss hier die oeffentlich erreichbare Listify-URL stehen, z. B. `https://listify.example.com/auth/sso/callback`.
|
||||||
|
|
||||||
|
### Listify Environment
|
||||||
|
|
||||||
|
Bei einem Keycloak-Realm `listify` unter `https://auth.forgecore.work`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OIDC_ISSUER_URL=https://auth.forgecore.work/realms/listify
|
||||||
|
OIDC_DISCOVERY_URL=
|
||||||
|
OIDC_CLIENT_ID=listify
|
||||||
|
OIDC_CLIENT_SECRET=<keycloak-client-secret>
|
||||||
|
OIDC_CALLBACK_URL=http://localhost:4200/auth/sso/callback
|
||||||
|
CLIENT_URL=http://localhost:4200
|
||||||
|
```
|
||||||
|
|
||||||
|
Wenn dein Realm anders heisst, muss nur der Realm-Teil angepasst werden. Die Discovery-URL wird automatisch aus dem Issuer gebildet:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://auth.forgecore.work/realms/<realm>/.well-known/openid-configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
Nur falls Keycloak hinter einem Proxy eine abweichende Discovery-URL liefert oder du sie explizit setzen willst:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OIDC_ISSUER_URL=https://auth.forgecore.work/realms/listify
|
||||||
|
OIDC_DISCOVERY_URL=https://auth.forgecore.work/realms/listify/.well-known/openid-configuration
|
||||||
|
```
|
||||||
|
|
||||||
## Run tests
|
## Run tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
20
listify-api/package-lock.json
generated
20
listify-api/package-lock.json
generated
@@ -22,6 +22,7 @@
|
|||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"handlebars": "^4.7.9",
|
"handlebars": "^4.7.9",
|
||||||
"helmet": "^8.2.0",
|
"helmet": "^8.2.0",
|
||||||
|
"jose": "^6.2.3",
|
||||||
"mysql2": "^3.22.5",
|
"mysql2": "^3.22.5",
|
||||||
"nodemailer": "^8.0.10",
|
"nodemailer": "^8.0.10",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
@@ -4563,7 +4564,7 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"normalize-path": "^3.0.0",
|
"normalize-path": "^3.0.0",
|
||||||
@@ -4577,7 +4578,7 @@
|
|||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
@@ -4894,7 +4895,7 @@
|
|||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fill-range": "^7.1.1"
|
"fill-range": "^7.1.1"
|
||||||
@@ -6955,7 +6956,7 @@
|
|||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"to-regex-range": "^5.0.1"
|
"to-regex-range": "^5.0.1"
|
||||||
@@ -7223,6 +7224,7 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -8006,7 +8008,7 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -8035,7 +8037,7 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-extglob": "^2.1.1"
|
"is-extglob": "^2.1.1"
|
||||||
@@ -8065,7 +8067,7 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
@@ -10646,7 +10648,7 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -13411,7 +13413,7 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-number": "^7.0.0"
|
"is-number": "^7.0.0"
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"handlebars": "^4.7.9",
|
"handlebars": "^4.7.9",
|
||||||
"helmet": "^8.2.0",
|
"helmet": "^8.2.0",
|
||||||
|
"jose": "^6.2.3",
|
||||||
"mysql2": "^3.22.5",
|
"mysql2": "^3.22.5",
|
||||||
"nodemailer": "^8.0.10",
|
"nodemailer": "^8.0.10",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ import { DatabaseLogger } from './database/database.logger';
|
|||||||
database: configService.get<string>('DB_DATABASE', 'listify'),
|
database: configService.get<string>('DB_DATABASE', 'listify'),
|
||||||
autoLoadEntities: true,
|
autoLoadEntities: true,
|
||||||
synchronize: true,
|
synchronize: true,
|
||||||
logging: parseDatabaseLogging(env.DB_LOGGING),
|
// logging: parseDatabaseLogging(env.DB_LOGGING),
|
||||||
logger: new DatabaseLogger(databaseLoggerOptionsFromEnv(env)),
|
// logger: new DatabaseLogger(databaseLoggerOptionsFromEnv(env)),
|
||||||
maxQueryExecutionTime: slowQueryThresholdFromEnv(env),
|
maxQueryExecutionTime: slowQueryThresholdFromEnv(env),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
export type AuditAction =
|
export type AuditAction =
|
||||||
| 'user.registered'
|
|
||||||
| 'user.email_verified'
|
|
||||||
| 'user.verification_resent'
|
|
||||||
| 'user.login_succeeded'
|
| 'user.login_succeeded'
|
||||||
| 'user.login_failed'
|
|
||||||
| 'user.token_refreshed'
|
| 'user.token_refreshed'
|
||||||
| 'user.onboarding_updated'
|
| 'user.onboarding_updated'
|
||||||
| 'user.task_digest_updated'
|
| 'user.task_digest_updated'
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
import type { AuthenticatedRequest } from './auth.types';
|
import type { AuthenticatedRequest } from './auth.types';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
@@ -44,6 +46,35 @@ export class AuthController {
|
|||||||
return this.authService.login(loginDto);
|
return this.authService.login(loginDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('sso/login')
|
||||||
|
async ssoLogin(@Res() response: Response) {
|
||||||
|
response.redirect(await this.authService.startSsoLogin());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('sso/callback')
|
||||||
|
async ssoCallback(
|
||||||
|
@Query('code') code: string | undefined,
|
||||||
|
@Query('state') state: string | undefined,
|
||||||
|
@Res() response: Response,
|
||||||
|
) {
|
||||||
|
const authResponse = await this.authService.completeSsoLogin(code, state);
|
||||||
|
const clientUrl = process.env.CLIENT_URL ?? 'http://localhost:4200';
|
||||||
|
const redirectUrl = new URL('/auth/sso/callback', clientUrl);
|
||||||
|
const fragment = new URLSearchParams({
|
||||||
|
accessToken: authResponse.accessToken,
|
||||||
|
refreshToken: authResponse.refreshToken,
|
||||||
|
user: JSON.stringify(authResponse.user),
|
||||||
|
});
|
||||||
|
|
||||||
|
response.redirect(`${redirectUrl.toString()}#${fragment.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sso/exchange')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
ssoExchange(@Body() body: { code?: string; state?: string }) {
|
||||||
|
return this.authService.completeSsoLogin(body.code, body.state);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
refresh(@Body() refreshTokenDto: RefreshTokenDto) {
|
refresh(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||||
return this.authService.refresh(refreshTokenDto);
|
return this.authService.refresh(refreshTokenDto);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { RefreshTokenEntity } from './refresh-token.entity';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
import { McpAuthGuard } from './mcp-auth.guard';
|
import { McpAuthGuard } from './mcp-auth.guard';
|
||||||
|
import { OidcService } from './oidc.service';
|
||||||
import { UserEntity } from './user.entity';
|
import { UserEntity } from './user.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -16,7 +17,7 @@ import { UserEntity } from './user.entity';
|
|||||||
TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]),
|
TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, JwtAuthGuard, McpAuthGuard],
|
providers: [AuthService, OidcService, JwtAuthGuard, McpAuthGuard],
|
||||||
exports: [AuthService, JwtAuthGuard, McpAuthGuard],
|
exports: [AuthService, JwtAuthGuard, McpAuthGuard],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -4,27 +4,40 @@ import { Test, TestingModule } from '@nestjs/testing';
|
|||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
import { AuthTokenResponse, JwtTokenPayload } from './auth.types';
|
import { AuthTokenResponse, JwtTokenPayload } from './auth.types';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { MailModule } from '../mail/mail.module';
|
import { OidcProfile, OidcService } from './oidc.service';
|
||||||
import { MailService } from '../mail/mail.service';
|
|
||||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||||
import { UserEntity } from './user.entity';
|
import { UserEntity } from './user.entity';
|
||||||
import { InMemoryRepository } from '../testing/in-memory-repository';
|
import { InMemoryRepository } from '../testing/in-memory-repository';
|
||||||
|
|
||||||
|
class FakeOidcService {
|
||||||
|
profile: OidcProfile = {
|
||||||
|
subject: 'oidc-user-1',
|
||||||
|
email: 'User@Example.com',
|
||||||
|
name: 'Test User',
|
||||||
|
};
|
||||||
|
|
||||||
|
createAuthorizationUrl = jest.fn(
|
||||||
|
async () => 'https://sso.example.test/authorize',
|
||||||
|
);
|
||||||
|
exchangeCallback = jest.fn(async () => this.profile);
|
||||||
|
}
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
let module: TestingModule;
|
let module: TestingModule;
|
||||||
let authService: AuthService;
|
let authService: AuthService;
|
||||||
let mailService: MailService;
|
|
||||||
let jwtService: JwtService;
|
let jwtService: JwtService;
|
||||||
|
let oidcService: FakeOidcService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
oidcService = new FakeOidcService();
|
||||||
module = await Test.createTestingModule({
|
module = await Test.createTestingModule({
|
||||||
imports: [
|
imports: [EventEmitterModule.forRoot(), JwtModule.register({})],
|
||||||
EventEmitterModule.forRoot(),
|
|
||||||
JwtModule.register({}),
|
|
||||||
MailModule,
|
|
||||||
],
|
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
|
{
|
||||||
|
provide: OidcService,
|
||||||
|
useValue: oidcService,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: getRepositoryToken(UserEntity),
|
provide: getRepositoryToken(UserEntity),
|
||||||
useValue: new InMemoryRepository<UserEntity>(),
|
useValue: new InMemoryRepository<UserEntity>(),
|
||||||
@@ -38,7 +51,6 @@ describe('AuthService', () => {
|
|||||||
await module.init();
|
await module.init();
|
||||||
|
|
||||||
authService = module.get<AuthService>(AuthService);
|
authService = module.get<AuthService>(AuthService);
|
||||||
mailService = module.get<MailService>(MailService);
|
|
||||||
jwtService = module.get<JwtService>(JwtService);
|
jwtService = module.get<JwtService>(JwtService);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,50 +58,55 @@ describe('AuthService', () => {
|
|||||||
await module.close();
|
await module.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('registers a user and sends a verification email', async () => {
|
it('starts the SSO login flow', async () => {
|
||||||
const response = await authService.register({
|
await expect(authService.startSsoLogin()).resolves.toBe(
|
||||||
|
'https://sso.example.test/authorize',
|
||||||
|
);
|
||||||
|
expect(oidcService.createAuthorizationUrl).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a local profile from the SSO callback', async () => {
|
||||||
|
const loginResponse = await authService.completeSsoLogin('code', 'state');
|
||||||
|
|
||||||
|
expect(loginResponse.accessToken).toBeDefined();
|
||||||
|
expect(loginResponse.refreshToken).toBeDefined();
|
||||||
|
expect(loginResponse.user.email).toBe('user@example.com');
|
||||||
|
expect(loginResponse.user.name).toBe('Test User');
|
||||||
|
expect(oidcService.exchangeCallback).toHaveBeenCalledWith('code', 'state');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses the same local user for the same SSO subject', async () => {
|
||||||
|
const firstLogin = await authService.completeSsoLogin('code', 'state');
|
||||||
|
oidcService.profile = {
|
||||||
|
subject: 'oidc-user-1',
|
||||||
|
email: 'renamed@example.com',
|
||||||
|
name: 'Renamed User',
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondLogin = await authService.completeSsoLogin('code', 'state');
|
||||||
|
|
||||||
|
expect(secondLogin.user.id).toBe(firstLogin.user.id);
|
||||||
|
expect(secondLogin.user.email).toBe('renamed@example.com');
|
||||||
|
expect(secondLogin.user.name).toBe('Renamed User');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links an existing local user by email on first SSO login', async () => {
|
||||||
|
const firstLogin = await authService.completeSsoLogin('code', 'state');
|
||||||
|
oidcService.profile = {
|
||||||
|
subject: 'oidc-user-2',
|
||||||
email: 'User@Example.com',
|
email: 'User@Example.com',
|
||||||
name: 'Test User',
|
name: 'Linked User',
|
||||||
password: 'password123',
|
};
|
||||||
});
|
|
||||||
|
|
||||||
const sentEmails = mailService.getSentEmails();
|
const secondLogin = await authService.completeSsoLogin('code', 'state');
|
||||||
|
|
||||||
expect(response.user.email).toBe('user@example.com');
|
expect(secondLogin.user.id).toBe(firstLogin.user.id);
|
||||||
expect(response.user.verified).toBe(false);
|
expect(secondLogin.user.email).toBe('user@example.com');
|
||||||
expect(sentEmails).toHaveLength(1);
|
expect(secondLogin.user.name).toBe('Linked User');
|
||||||
expect(sentEmails[0].to).toBe('user@example.com');
|
|
||||||
expect(sentEmails[0].verificationUrl).toContain('/verify-email?token=');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects login before email verification', async () => {
|
it('issues SSO-backed JWTs on login', async () => {
|
||||||
await authService.register({
|
const loginResponse = await authService.completeSsoLogin('code', 'state');
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
authService.login({
|
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('Please verify your email before login.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('verifies email and allows login afterwards', async () => {
|
|
||||||
await authService.register({
|
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
});
|
|
||||||
|
|
||||||
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
|
|
||||||
const token = new URL(verificationUrl).searchParams.get('token');
|
|
||||||
|
|
||||||
const verifyResponse = await authService.verifyEmail(token ?? undefined);
|
|
||||||
const loginResponse = await authService.login({
|
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
});
|
|
||||||
const accessPayload = jwtService.verify<JwtTokenPayload>(
|
const accessPayload = jwtService.verify<JwtTokenPayload>(
|
||||||
loginResponse.accessToken,
|
loginResponse.accessToken,
|
||||||
{
|
{
|
||||||
@@ -103,7 +120,6 @@ describe('AuthService', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(verifyResponse.user.verified).toBe(true);
|
|
||||||
expect(loginResponse.accessToken).toBeDefined();
|
expect(loginResponse.accessToken).toBeDefined();
|
||||||
expect(loginResponse.refreshToken).toBeDefined();
|
expect(loginResponse.refreshToken).toBeDefined();
|
||||||
expect(loginResponse.user.email).toBe('user@example.com');
|
expect(loginResponse.user.email).toBe('user@example.com');
|
||||||
@@ -114,19 +130,7 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rotates refresh tokens and rejects reuse', async () => {
|
it('rotates refresh tokens and rejects reuse', async () => {
|
||||||
await authService.register({
|
const loginResponse = await loginWithSso();
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
});
|
|
||||||
|
|
||||||
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
|
|
||||||
const token = new URL(verificationUrl).searchParams.get('token');
|
|
||||||
await authService.verifyEmail(token ?? undefined);
|
|
||||||
|
|
||||||
const loginResponse = await authService.login({
|
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
});
|
|
||||||
const refreshResponse = await authService.refresh({
|
const refreshResponse = await authService.refresh({
|
||||||
refreshToken: loginResponse.refreshToken,
|
refreshToken: loginResponse.refreshToken,
|
||||||
});
|
});
|
||||||
@@ -140,19 +144,7 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects access tokens on the refresh endpoint', async () => {
|
it('rejects access tokens on the refresh endpoint', async () => {
|
||||||
await authService.register({
|
const loginResponse = await loginWithSso();
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
});
|
|
||||||
|
|
||||||
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
|
|
||||||
const token = new URL(verificationUrl).searchParams.get('token');
|
|
||||||
await authService.verifyEmail(token ?? undefined);
|
|
||||||
|
|
||||||
const loginResponse = await authService.login({
|
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
authService.refresh({ refreshToken: loginResponse.accessToken }),
|
authService.refresh({ refreshToken: loginResponse.accessToken }),
|
||||||
@@ -160,19 +152,7 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('validates access tokens', async () => {
|
it('validates access tokens', async () => {
|
||||||
await authService.register({
|
const loginResponse = await loginWithSso();
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
});
|
|
||||||
|
|
||||||
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
|
|
||||||
const token = new URL(verificationUrl).searchParams.get('token');
|
|
||||||
await authService.verifyEmail(token ?? undefined);
|
|
||||||
|
|
||||||
const loginResponse = await authService.login({
|
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
});
|
|
||||||
const payload = await authService.verifyAccessToken(
|
const payload = await authService.verifyAccessToken(
|
||||||
loginResponse.accessToken,
|
loginResponse.accessToken,
|
||||||
);
|
);
|
||||||
@@ -184,33 +164,23 @@ describe('AuthService', () => {
|
|||||||
).rejects.toThrow('Access token is invalid.');
|
).rejects.toThrow('Access token is invalid.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects duplicate registrations', async () => {
|
it('rejects password registration and login endpoints', async () => {
|
||||||
await authService.register({
|
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
authService.register({
|
authService.register({
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
password: 'password123',
|
password: 'password123',
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow('Email is already registered.');
|
).rejects.toThrow('Registration is handled by the SSO provider.');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
authService.login({
|
||||||
|
email: 'user@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Login is handled by SSO.');
|
||||||
});
|
});
|
||||||
|
|
||||||
async function registerVerifiedUserAndLogin(): Promise<AuthTokenResponse> {
|
async function loginWithSso(): Promise<AuthTokenResponse> {
|
||||||
await authService.register({
|
return authService.completeSsoLogin('code', 'state');
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
});
|
|
||||||
|
|
||||||
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
|
|
||||||
const token = new URL(verificationUrl).searchParams.get('token');
|
|
||||||
await authService.verifyEmail(token ?? undefined);
|
|
||||||
|
|
||||||
return authService.login({
|
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
GoneException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Optional,
|
Optional,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto';
|
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto';
|
||||||
@@ -22,8 +21,8 @@ import {
|
|||||||
PublicUser,
|
PublicUser,
|
||||||
PublicUserSearchResult,
|
PublicUserSearchResult,
|
||||||
} from './auth.types';
|
} from './auth.types';
|
||||||
import { AppEvents } from '../events/app-events';
|
|
||||||
import type { TaskDigestPreference } from '../tasks/task-digest.types';
|
import type { TaskDigestPreference } from '../tasks/task-digest.types';
|
||||||
|
import { OidcProfile, OidcService } from './oidc.service';
|
||||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||||
import { UserEntity } from './user.entity';
|
import { UserEntity } from './user.entity';
|
||||||
|
|
||||||
@@ -37,8 +36,8 @@ export class AuthService {
|
|||||||
process.env.JWT_REFRESH_SECRET ?? 'dev-refresh-secret';
|
process.env.JWT_REFRESH_SECRET ?? 'dev-refresh-secret';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly eventEmitter: EventEmitter2,
|
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly oidcService: OidcService,
|
||||||
@InjectRepository(UserEntity)
|
@InjectRepository(UserEntity)
|
||||||
private readonly usersRepository: Repository<UserEntity>,
|
private readonly usersRepository: Repository<UserEntity>,
|
||||||
@InjectRepository(RefreshTokenEntity)
|
@InjectRepository(RefreshTokenEntity)
|
||||||
@@ -50,145 +49,46 @@ export class AuthService {
|
|||||||
async register(
|
async register(
|
||||||
registerDto: RegisterDto,
|
registerDto: RegisterDto,
|
||||||
): Promise<{ message: string; user: PublicUser }> {
|
): Promise<{ message: string; user: PublicUser }> {
|
||||||
const email = this.normalizeEmail(registerDto.email);
|
void registerDto;
|
||||||
const password = this.requirePassword(registerDto.password);
|
throw new GoneException('Registration is handled by the SSO provider.');
|
||||||
const name = this.normalizeName(registerDto.name);
|
|
||||||
|
|
||||||
const existingUser = await this.usersRepository.findOne({
|
|
||||||
where: { email },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingUser) {
|
|
||||||
throw new ConflictException('Email is already registered.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const verificationToken = this.createToken();
|
|
||||||
const user = this.usersRepository.create({
|
|
||||||
id: randomUUID(),
|
|
||||||
email,
|
|
||||||
name,
|
|
||||||
passwordHash: this.hashPassword(password),
|
|
||||||
verificationToken,
|
|
||||||
verified: false,
|
|
||||||
});
|
|
||||||
const savedUser = await this.usersRepository.save(user);
|
|
||||||
|
|
||||||
await this.auditLogService?.record({
|
|
||||||
actorUserId: savedUser.id,
|
|
||||||
actorEmail: savedUser.email,
|
|
||||||
action: 'user.registered',
|
|
||||||
entityType: 'user',
|
|
||||||
entityId: savedUser.id,
|
|
||||||
metadata: { verified: savedUser.verified },
|
|
||||||
});
|
|
||||||
|
|
||||||
this.eventEmitter.emit(AppEvents.UserRegistered, {
|
|
||||||
email,
|
|
||||||
verificationUrl: this.createVerificationUrl(verificationToken),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: 'Registration successful. Please verify your email address.',
|
|
||||||
user: this.toPublicUser(savedUser),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyEmail(
|
async verifyEmail(
|
||||||
token?: string,
|
token?: string,
|
||||||
): Promise<{ message: string; user: PublicUser }> {
|
): Promise<{ message: string; user: PublicUser }> {
|
||||||
if (!token) {
|
void token;
|
||||||
throw new BadRequestException('Verification token is required.');
|
throw new GoneException('Email verification is no longer required.');
|
||||||
}
|
|
||||||
|
|
||||||
const user = await this.usersRepository.findOne({
|
|
||||||
where: { verificationToken: token },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new BadRequestException('Verification token is invalid.');
|
|
||||||
}
|
|
||||||
|
|
||||||
user.verified = true;
|
|
||||||
user.verificationToken = null;
|
|
||||||
try {
|
|
||||||
const savedUser = await this.usersRepository.save(user);
|
|
||||||
|
|
||||||
await this.auditLogService?.record({
|
|
||||||
actorUserId: savedUser.id,
|
|
||||||
actorEmail: savedUser.email,
|
|
||||||
action: 'user.email_verified',
|
|
||||||
entityType: 'user',
|
|
||||||
entityId: savedUser.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: 'Email verified successfully.',
|
|
||||||
user: this.toPublicUser(savedUser),
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
throw new BadRequestException('user not saved.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async resendVerificationEmail(
|
async resendVerificationEmail(
|
||||||
resendVerificationDto: ResendVerificationDto,
|
resendVerificationDto: ResendVerificationDto,
|
||||||
): Promise<{ message: string }> {
|
): Promise<{ message: string }> {
|
||||||
const email = this.normalizeEmail(resendVerificationDto.email);
|
void resendVerificationDto;
|
||||||
const message =
|
throw new GoneException('Email verification is no longer required.');
|
||||||
'Falls ein unverifiziertes Konto mit dieser E-Mail existiert, wurde eine neue Verifizierungsmail versendet.';
|
|
||||||
const user = await this.usersRepository.findOne({ where: { email } });
|
|
||||||
|
|
||||||
if (!user || user.verified) {
|
|
||||||
return { message };
|
|
||||||
}
|
|
||||||
|
|
||||||
user.verificationToken = this.createToken();
|
|
||||||
const savedUser = await this.usersRepository.save(user);
|
|
||||||
|
|
||||||
await this.auditLogService?.record({
|
|
||||||
actorUserId: savedUser.id,
|
|
||||||
actorEmail: savedUser.email,
|
|
||||||
action: 'user.verification_resent',
|
|
||||||
entityType: 'user',
|
|
||||||
entityId: savedUser.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.eventEmitter.emit(AppEvents.UserRegistered, {
|
|
||||||
email: savedUser.email,
|
|
||||||
verificationUrl: this.createVerificationUrl(savedUser.verificationToken!),
|
|
||||||
});
|
|
||||||
|
|
||||||
return { message };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(loginDto: LoginDto): Promise<AuthTokenResponse> {
|
async login(loginDto: LoginDto): Promise<AuthTokenResponse> {
|
||||||
const email = this.normalizeEmail(loginDto.email);
|
void loginDto;
|
||||||
const password = this.requirePassword(loginDto.password);
|
throw new GoneException('Login is handled by SSO.');
|
||||||
const user = await this.usersRepository.findOne({ where: { email } });
|
}
|
||||||
|
|
||||||
if (!user || !this.passwordMatches(password, user.passwordHash)) {
|
startSsoLogin(): Promise<string> {
|
||||||
await this.auditLogService?.record({
|
return this.oidcService.createAuthorizationUrl();
|
||||||
actorEmail: email,
|
}
|
||||||
action: 'user.login_failed',
|
|
||||||
entityType: 'user',
|
|
||||||
entityId: user?.id,
|
|
||||||
metadata: { reason: 'invalid_credentials' },
|
|
||||||
});
|
|
||||||
throw new UnauthorizedException('Invalid email or password.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.verified) {
|
|
||||||
await this.auditLogService?.record({
|
|
||||||
actorUserId: user.id,
|
|
||||||
actorEmail: user.email,
|
|
||||||
action: 'user.login_failed',
|
|
||||||
entityType: 'user',
|
|
||||||
entityId: user.id,
|
|
||||||
metadata: { reason: 'email_not_verified' },
|
|
||||||
});
|
|
||||||
throw new UnauthorizedException('Please verify your email before login.');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
async completeSsoLogin(
|
||||||
|
code?: string,
|
||||||
|
state?: string,
|
||||||
|
): Promise<AuthTokenResponse> {
|
||||||
|
const profile = await this.oidcService.exchangeCallback(code, state);
|
||||||
|
const existingUser =
|
||||||
|
(await this.usersRepository.findOne({
|
||||||
|
where: { oidcSubject: profile.subject },
|
||||||
|
})) ??
|
||||||
|
(await this.usersRepository.findOne({
|
||||||
|
where: { email: this.normalizeEmail(profile.email) },
|
||||||
|
}));
|
||||||
|
const user = await this.syncOidcUser(profile, existingUser);
|
||||||
const response = {
|
const response = {
|
||||||
...(await this.createAuthTokens(user)),
|
...(await this.createAuthTokens(user)),
|
||||||
user: this.toPublicUser(user),
|
user: this.toPublicUser(user),
|
||||||
@@ -200,6 +100,7 @@ export class AuthService {
|
|||||||
action: 'user.login_succeeded',
|
action: 'user.login_succeeded',
|
||||||
entityType: 'user',
|
entityType: 'user',
|
||||||
entityId: user.id,
|
entityId: user.id,
|
||||||
|
metadata: { directory: 'oidc', oidcSubject: user.oidcSubject },
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -227,7 +128,7 @@ export class AuthService {
|
|||||||
where: { id: payload.sub },
|
where: { id: payload.sub },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !user.verified) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('Refresh token is invalid.');
|
throw new UnauthorizedException('Refresh token is invalid.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +164,7 @@ export class AuthService {
|
|||||||
where: { id: payload.sub },
|
where: { id: payload.sub },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !user.verified) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('Access token is invalid.');
|
throw new UnauthorizedException('Access token is invalid.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,10 +210,7 @@ export class AuthService {
|
|||||||
|
|
||||||
const pattern = `%${normalizedQuery}%`;
|
const pattern = `%${normalizedQuery}%`;
|
||||||
const users = await this.usersRepository.find({
|
const users = await this.usersRepository.find({
|
||||||
where: [
|
where: [{ email: Like(pattern) }, { name: Like(pattern) }],
|
||||||
{ verified: true, email: Like(pattern) },
|
|
||||||
{ verified: true, name: Like(pattern) },
|
|
||||||
],
|
|
||||||
order: { email: 'ASC' },
|
order: { email: 'ASC' },
|
||||||
take: 10,
|
take: 10,
|
||||||
});
|
});
|
||||||
@@ -414,30 +312,35 @@ export class AuthService {
|
|||||||
throw new BadRequestException('Task digest preference is invalid.');
|
throw new BadRequestException('Task digest preference is invalid.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private requirePassword(password?: string): string {
|
private async syncOidcUser(
|
||||||
if (!password || password.length < 8) {
|
profile: OidcProfile,
|
||||||
throw new BadRequestException(
|
existingUser?: UserEntity | null,
|
||||||
'Password must contain at least 8 characters.',
|
): Promise<UserEntity> {
|
||||||
);
|
const user =
|
||||||
}
|
existingUser ??
|
||||||
|
this.usersRepository.create({
|
||||||
|
id: randomUUID(),
|
||||||
|
onboardingCompleted: false,
|
||||||
|
taskDigestPreference: 'both',
|
||||||
|
});
|
||||||
|
|
||||||
return password;
|
user.email = this.normalizeEmail(profile.email);
|
||||||
|
user.name = this.normalizeName(profile.name);
|
||||||
|
user.oidcSubject = profile.subject;
|
||||||
|
user.onboardingCompleted = user.onboardingCompleted === true;
|
||||||
|
user.taskDigestPreference = user.taskDigestPreference ?? 'both';
|
||||||
|
|
||||||
|
return this.usersRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
private hashPassword(password: string): string {
|
private secretMatches(secret: string, storedSecretHash: string): boolean {
|
||||||
const salt = randomBytes(16).toString('hex');
|
const [salt, storedHash] = storedSecretHash.split(':');
|
||||||
const hash = scryptSync(password, salt, 64).toString('hex');
|
|
||||||
return `${salt}:${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private passwordMatches(password: string, passwordHash: string): boolean {
|
|
||||||
const [salt, storedHash] = passwordHash.split(':');
|
|
||||||
|
|
||||||
if (!salt || !storedHash) {
|
if (!salt || !storedHash) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attemptedHash = scryptSync(password, salt, 64);
|
const attemptedHash = scryptSync(secret, salt, 64);
|
||||||
const storedHashBuffer = Buffer.from(storedHash, 'hex');
|
const storedHashBuffer = Buffer.from(storedHash, 'hex');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -513,16 +416,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private tokenMatches(token: string, tokenHash: string): boolean {
|
private tokenMatches(token: string, tokenHash: string): boolean {
|
||||||
return this.passwordMatches(token, tokenHash);
|
return this.secretMatches(token, tokenHash);
|
||||||
}
|
|
||||||
|
|
||||||
private createToken(): string {
|
|
||||||
return randomBytes(32).toString('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
private createVerificationUrl(token: string): string {
|
|
||||||
const clientUrl = process.env.CLIENT_URL ?? 'http://localhost:4200';
|
|
||||||
return `${clientUrl}/verify-email?token=${token}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private toPublicUser(user: UserEntity): PublicUser {
|
private toPublicUser(user: UserEntity): PublicUser {
|
||||||
@@ -530,7 +424,6 @@ export class AuthService {
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name ?? undefined,
|
name: user.name ?? undefined,
|
||||||
verified: user.verified,
|
|
||||||
onboardingCompleted: user.onboardingCompleted === true,
|
onboardingCompleted: user.onboardingCompleted === true,
|
||||||
taskDigestPreference: user.taskDigestPreference ?? 'both',
|
taskDigestPreference: user.taskDigestPreference ?? 'both',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import type { TaskDigestPreference } from '../tasks/task-digest.types';
|
import type { TaskDigestPreference } from '../tasks/task-digest.types';
|
||||||
|
|
||||||
export interface AuthUser {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name?: string;
|
|
||||||
passwordHash: string;
|
|
||||||
verificationToken?: string;
|
|
||||||
verified: boolean;
|
|
||||||
onboardingCompleted: boolean;
|
|
||||||
taskDigestPreference: TaskDigestPreference;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthTokens {
|
export interface AuthTokens {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
@@ -36,7 +25,6 @@ export interface PublicUser {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
verified: boolean;
|
|
||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
taskDigestPreference: TaskDigestPreference;
|
taskDigestPreference: TaskDigestPreference;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AuthenticatedRequest } from './auth.types';
|
import { AuthenticatedRequest } from './auth.types';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
import { MailModule } from '../mail/mail.module';
|
import { OidcService } from './oidc.service';
|
||||||
import { MailService } from '../mail/mail.service';
|
|
||||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||||
import { UserEntity } from './user.entity';
|
import { UserEntity } from './user.entity';
|
||||||
import { InMemoryRepository } from '../testing/in-memory-repository';
|
import { InMemoryRepository } from '../testing/in-memory-repository';
|
||||||
@@ -16,14 +15,26 @@ describe('JwtAuthGuard', () => {
|
|||||||
let module: TestingModule;
|
let module: TestingModule;
|
||||||
let authService: AuthService;
|
let authService: AuthService;
|
||||||
let guard: JwtAuthGuard;
|
let guard: JwtAuthGuard;
|
||||||
let mailService: MailService;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
module = await Test.createTestingModule({
|
module = await Test.createTestingModule({
|
||||||
imports: [EventEmitterModule.forRoot(), JwtModule.register({}), MailModule],
|
imports: [EventEmitterModule.forRoot(), JwtModule.register({})],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
JwtAuthGuard,
|
JwtAuthGuard,
|
||||||
|
{
|
||||||
|
provide: OidcService,
|
||||||
|
useValue: {
|
||||||
|
createAuthorizationUrl: jest.fn(
|
||||||
|
async () => 'https://sso.example.test/authorize',
|
||||||
|
),
|
||||||
|
exchangeCallback: jest.fn(async () => ({
|
||||||
|
subject: 'oidc-user-1',
|
||||||
|
email: 'user@example.com',
|
||||||
|
name: 'Test User',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: getRepositoryToken(UserEntity),
|
provide: getRepositoryToken(UserEntity),
|
||||||
useValue: new InMemoryRepository<UserEntity>(),
|
useValue: new InMemoryRepository<UserEntity>(),
|
||||||
@@ -38,7 +49,6 @@ describe('JwtAuthGuard', () => {
|
|||||||
|
|
||||||
authService = module.get<AuthService>(AuthService);
|
authService = module.get<AuthService>(AuthService);
|
||||||
guard = module.get<JwtAuthGuard>(JwtAuthGuard);
|
guard = module.get<JwtAuthGuard>(JwtAuthGuard);
|
||||||
mailService = module.get<MailService>(MailService);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -78,19 +88,7 @@ describe('JwtAuthGuard', () => {
|
|||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
}> {
|
}> {
|
||||||
await authService.register({
|
return authService.completeSsoLogin('code', 'state');
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
});
|
|
||||||
|
|
||||||
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
|
|
||||||
const token = new URL(verificationUrl).searchParams.get('token');
|
|
||||||
await authService.verifyEmail(token ?? undefined);
|
|
||||||
|
|
||||||
return authService.login({
|
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRequest(
|
function createRequest(
|
||||||
|
|||||||
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 })
|
@Column({ type: 'varchar', length: 320 })
|
||||||
email!: string;
|
email!: string;
|
||||||
|
|
||||||
|
@Index({ unique: true })
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true, select: false })
|
||||||
|
oidcSubject?: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 160, nullable: true })
|
@Column({ type: 'varchar', length: 160, nullable: true })
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 255 })
|
|
||||||
passwordHash!: string;
|
|
||||||
|
|
||||||
@Index({ unique: true })
|
|
||||||
@Column({ type: 'varchar', length: 128, nullable: true })
|
|
||||||
verificationToken?: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: false })
|
|
||||||
verified!: boolean;
|
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: false })
|
@Column({ type: 'boolean', default: false })
|
||||||
onboardingCompleted!: boolean;
|
onboardingCompleted!: boolean;
|
||||||
|
|
||||||
@@ -51,7 +45,7 @@ export class UserEntity {
|
|||||||
taskDigestAfternoonProcessedDate?: string | null;
|
taskDigestAfternoonProcessedDate?: string | null;
|
||||||
|
|
||||||
@Index('IDX_users_mcp_api_key_hash', { unique: true })
|
@Index('IDX_users_mcp_api_key_hash', { unique: true })
|
||||||
@Column({ type: 'varchar', length: 64, nullable: true })
|
@Column({ type: 'varchar', length: 64, nullable: true, select: false })
|
||||||
mcpApiKeyHash?: string | null;
|
mcpApiKeyHash?: string | null;
|
||||||
|
|
||||||
@Column({ type: 'datetime', precision: 3, nullable: true })
|
@Column({ type: 'datetime', precision: 3, nullable: true })
|
||||||
|
|||||||
@@ -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({
|
await usersRepository.save({
|
||||||
id: 'user-2',
|
id: 'user-2',
|
||||||
email: 'collaborator@example.com',
|
email: 'collaborator@example.com',
|
||||||
passwordHash: 'hash',
|
|
||||||
verified: true,
|
|
||||||
onboardingCompleted: false,
|
onboardingCompleted: false,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export class ListTemplatesService {
|
|||||||
where: { id: targetUserId },
|
where: { id: targetUserId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!targetUser || !targetUser.verified) {
|
if (!targetUser) {
|
||||||
throw new NotFoundException('User was not found.');
|
throw new NotFoundException('User was not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -179,8 +179,6 @@ describe('ListsService', () => {
|
|||||||
await usersRepository.save({
|
await usersRepository.save({
|
||||||
id: 'user-2',
|
id: 'user-2',
|
||||||
email: 'collaborator@example.com',
|
email: 'collaborator@example.com',
|
||||||
passwordHash: 'hash',
|
|
||||||
verified: true,
|
|
||||||
onboardingCompleted: false,
|
onboardingCompleted: false,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -351,8 +349,6 @@ describe('ListsService', () => {
|
|||||||
await usersRepository.save({
|
await usersRepository.save({
|
||||||
id: 'user-2',
|
id: 'user-2',
|
||||||
email: 'collaborator@example.com',
|
email: 'collaborator@example.com',
|
||||||
passwordHash: 'hash',
|
|
||||||
verified: true,
|
|
||||||
onboardingCompleted: false,
|
onboardingCompleted: false,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ export class ListsService {
|
|||||||
where: { id: targetUserId },
|
where: { id: targetUserId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!targetUser || !targetUser.verified) {
|
if (!targetUser) {
|
||||||
throw new NotFoundException('User was not found.');
|
throw new NotFoundException('User was not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,8 +226,6 @@ describe('TaskDigestService', () => {
|
|||||||
id: overrides.id ?? 'owner-1',
|
id: overrides.id ?? 'owner-1',
|
||||||
email: overrides.email ?? 'owner@example.com',
|
email: overrides.email ?? 'owner@example.com',
|
||||||
name: overrides.name ?? 'Owner',
|
name: overrides.name ?? 'Owner',
|
||||||
passwordHash: 'hash',
|
|
||||||
verified: overrides.verified ?? true,
|
|
||||||
onboardingCompleted: false,
|
onboardingCompleted: false,
|
||||||
taskDigestPreference: overrides.taskDigestPreference ?? 'both',
|
taskDigestPreference: overrides.taskDigestPreference ?? 'both',
|
||||||
taskDigestMorningProcessedDate:
|
taskDigestMorningProcessedDate:
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ export class TaskDigestService {
|
|||||||
try {
|
try {
|
||||||
const dateKey = this.dateKey(now);
|
const dateKey = this.dateKey(now);
|
||||||
const users = await this.usersRepository.find({
|
const users = await this.usersRepository.find({
|
||||||
where: { verified: true },
|
|
||||||
order: { email: 'ASC' },
|
order: { email: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing';
|
|||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { App } from 'supertest/types';
|
import { App } from 'supertest/types';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { AppModule } from './../src/app.module';
|
import { AppModule } from './../src/app.module';
|
||||||
import { MailService } from './../src/mail/mail.service';
|
import { OidcProfile, OidcService } from '../src/auth/oidc.service';
|
||||||
|
|
||||||
interface AuthResponseBody {
|
interface AuthResponseBody {
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
@@ -11,7 +12,6 @@ interface AuthResponseBody {
|
|||||||
user: {
|
user: {
|
||||||
id?: string;
|
id?: string;
|
||||||
email: string;
|
email: string;
|
||||||
verified: boolean;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,16 +28,36 @@ interface ListTemplateResponseBody {
|
|||||||
|
|
||||||
describe('AppController (e2e)', () => {
|
describe('AppController (e2e)', () => {
|
||||||
let app: INestApplication<App>;
|
let app: INestApplication<App>;
|
||||||
let mailService: MailService;
|
let oidcService: {
|
||||||
|
createAuthorizationUrl: jest.Mock<Promise<string>, []>;
|
||||||
|
exchangeCallback: jest.Mock<
|
||||||
|
Promise<OidcProfile>,
|
||||||
|
[string | undefined, string | undefined]
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
oidcService = {
|
||||||
|
createAuthorizationUrl: jest.fn(
|
||||||
|
async () => 'https://sso.example.test/authorize',
|
||||||
|
),
|
||||||
|
exchangeCallback: jest.fn(async () => ({
|
||||||
|
subject: 'oidc-default',
|
||||||
|
email: 'default@example.com',
|
||||||
|
name: 'Default User',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
imports: [AppModule],
|
imports: [AppModule],
|
||||||
}).compile();
|
})
|
||||||
|
.overrideProvider(OidcService)
|
||||||
|
.useValue(oidcService)
|
||||||
|
.compile();
|
||||||
|
|
||||||
mailService = moduleFixture.get<MailService>(MailService);
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
await app.init();
|
await app.init();
|
||||||
|
await ensureSsoSchema(app.get(DataSource));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/ (GET)', () => {
|
it('/ (GET)', () => {
|
||||||
@@ -47,50 +67,13 @@ describe('AppController (e2e)', () => {
|
|||||||
.expect('Hello World!');
|
.expect('Hello World!');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/auth register, verify and login', async () => {
|
it('/auth sso callback and refresh', async () => {
|
||||||
const registerResponse = await request(app.getHttpServer())
|
const email = uniqueEmail('auth-user');
|
||||||
.post('/auth/register')
|
const loginBody = await loginWithSso(email);
|
||||||
.send({
|
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
})
|
|
||||||
.expect(201);
|
|
||||||
|
|
||||||
const registerBody = registerResponse.body as unknown as AuthResponseBody;
|
|
||||||
expect(registerBody.user.email).toBe('user@example.com');
|
|
||||||
expect(registerBody.user.verified).toBe(false);
|
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.post('/auth/login')
|
|
||||||
.send({
|
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
})
|
|
||||||
.expect(401);
|
|
||||||
|
|
||||||
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
|
|
||||||
const token = new URL(verificationUrl).searchParams.get('token');
|
|
||||||
|
|
||||||
const verifyResponse = await request(app.getHttpServer())
|
|
||||||
.get('/auth/verify-email')
|
|
||||||
.query({ token })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
const verifyBody = verifyResponse.body as unknown as AuthResponseBody;
|
|
||||||
expect(verifyBody.user.verified).toBe(true);
|
|
||||||
|
|
||||||
const loginResponse = await request(app.getHttpServer())
|
|
||||||
.post('/auth/login')
|
|
||||||
.send({
|
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
})
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
const loginBody = loginResponse.body as unknown as AuthResponseBody;
|
|
||||||
expect(loginBody.accessToken).toBeDefined();
|
expect(loginBody.accessToken).toBeDefined();
|
||||||
expect(loginBody.refreshToken).toBeDefined();
|
expect(loginBody.refreshToken).toBeDefined();
|
||||||
expect(loginBody.user.email).toBe('user@example.com');
|
expect(loginBody.user.email).toBe(email);
|
||||||
|
|
||||||
const refreshResponse = await request(app.getHttpServer())
|
const refreshResponse = await request(app.getHttpServer())
|
||||||
.post('/auth/refresh')
|
.post('/auth/refresh')
|
||||||
@@ -113,8 +96,8 @@ describe('AppController (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('/list-templates creates, updates and uses a template', async () => {
|
it('/list-templates creates, updates and uses a template', async () => {
|
||||||
const accessToken = await registerVerifiedUserAndGetAccessToken(
|
const accessToken = await loginWithSsoAndGetAccessToken(
|
||||||
'template-user@example.com',
|
uniqueEmail('template-user'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const initialTemplatesResponse = await request(app.getHttpServer())
|
const initialTemplatesResponse = await request(app.getHttpServer())
|
||||||
@@ -172,8 +155,8 @@ describe('AppController (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('/lists creates, updates and reads a concrete list', async () => {
|
it('/lists creates, updates and reads a concrete list', async () => {
|
||||||
const accessToken = await registerVerifiedUserAndGetAccessToken(
|
const accessToken = await loginWithSsoAndGetAccessToken(
|
||||||
'list-user@example.com',
|
uniqueEmail('list-user'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const createListResponse = await request(app.getHttpServer())
|
const createListResponse = await request(app.getHttpServer())
|
||||||
@@ -222,41 +205,75 @@ describe('AppController (e2e)', () => {
|
|||||||
expect(fetchedList.items[0].checked).toBe(true);
|
expect(fetchedList.items[0].checked).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function registerVerifiedUserAndGetAccessToken(
|
async function loginWithSsoAndGetAccessToken(
|
||||||
email: string,
|
email: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
await request(app.getHttpServer())
|
const loginBody = await loginWithSso(email);
|
||||||
.post('/auth/register')
|
|
||||||
.send({
|
|
||||||
email,
|
|
||||||
password: 'password123',
|
|
||||||
})
|
|
||||||
.expect(201);
|
|
||||||
|
|
||||||
const verificationUrl =
|
|
||||||
mailService.getSentEmails()[mailService.getSentEmails().length - 1]
|
|
||||||
.verificationUrl;
|
|
||||||
const token = new URL(verificationUrl).searchParams.get('token');
|
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.get('/auth/verify-email')
|
|
||||||
.query({ token })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
const loginResponse = await request(app.getHttpServer())
|
|
||||||
.post('/auth/login')
|
|
||||||
.send({
|
|
||||||
email,
|
|
||||||
password: 'password123',
|
|
||||||
})
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
const loginBody = loginResponse.body as unknown as AuthResponseBody;
|
|
||||||
expect(loginBody.accessToken).toBeDefined();
|
expect(loginBody.accessToken).toBeDefined();
|
||||||
|
|
||||||
return loginBody.accessToken ?? '';
|
return loginBody.accessToken ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loginWithSso(email: string): Promise<AuthResponseBody> {
|
||||||
|
oidcService.exchangeCallback.mockResolvedValueOnce({
|
||||||
|
subject: `sub-${email}`,
|
||||||
|
email,
|
||||||
|
name: 'Test User',
|
||||||
|
});
|
||||||
|
|
||||||
|
const exchangeResponse = await request(app.getHttpServer())
|
||||||
|
.post('/auth/sso/exchange')
|
||||||
|
.send({ code: 'code', state: 'state' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
return exchangeResponse.body as unknown as AuthResponseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueEmail(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}@example.com`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSsoSchema(dataSource: DataSource): Promise<void> {
|
||||||
|
const usersTables = (await dataSource.query(
|
||||||
|
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users'",
|
||||||
|
)) as unknown[];
|
||||||
|
|
||||||
|
if (!usersTables.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oidcSubjectColumns = (await dataSource.query(
|
||||||
|
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'oidcSubject'",
|
||||||
|
)) as unknown[];
|
||||||
|
|
||||||
|
if (!oidcSubjectColumns.length) {
|
||||||
|
await dataSource.query(
|
||||||
|
'ALTER TABLE `users` ADD `oidcSubject` varchar(255) NULL',
|
||||||
|
);
|
||||||
|
await dataSource.query(
|
||||||
|
'CREATE UNIQUE INDEX `IDX_users_oidc_subject` ON `users` (`oidcSubject`)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await dropColumnIfExists(dataSource, 'passwordHash');
|
||||||
|
await dropColumnIfExists(dataSource, 'verificationToken');
|
||||||
|
await dropColumnIfExists(dataSource, 'verified');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropColumnIfExists(
|
||||||
|
dataSource: DataSource,
|
||||||
|
columnName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const columns = (await dataSource.query(
|
||||||
|
'SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
|
||||||
|
['users', columnName],
|
||||||
|
)) as unknown[];
|
||||||
|
|
||||||
|
if (columns.length) {
|
||||||
|
await dataSource.query(`ALTER TABLE \`users\` DROP COLUMN \`${columnName}\``);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="status-row">
|
<div class="status-row">
|
||||||
<mat-icon aria-hidden="true">verified_user</mat-icon>
|
<mat-icon aria-hidden="true">verified_user</mat-icon>
|
||||||
<span>{{ auth.user()?.verified ? 'E-Mail verifiziert' : 'E-Mail nicht verifiziert' }}</span>
|
<span>SSO-Konto aktiv</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status-row">
|
<div class="status-row">
|
||||||
|
|||||||
@@ -41,15 +41,6 @@
|
|||||||
<mat-icon aria-hidden="true">login</mat-icon>
|
<mat-icon aria-hidden="true">login</mat-icon>
|
||||||
Login
|
Login
|
||||||
</a>
|
</a>
|
||||||
<a
|
|
||||||
mat-flat-button
|
|
||||||
routerLink="/register"
|
|
||||||
routerLinkActive="active-link"
|
|
||||||
ariaCurrentWhenActive="page"
|
|
||||||
>
|
|
||||||
<mat-icon aria-hidden="true">person_add</mat-icon>
|
|
||||||
Registrieren
|
|
||||||
</a>
|
|
||||||
}
|
}
|
||||||
</mat-toolbar>
|
</mat-toolbar>
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { Routes } from '@angular/router';
|
|||||||
import { authGuard } from './auth/auth.guard';
|
import { authGuard } from './auth/auth.guard';
|
||||||
import { unauthGuard } from './auth/unauth.guard';
|
import { unauthGuard } from './auth/unauth.guard';
|
||||||
import { LoginComponent } from './auth/login/login.component';
|
import { LoginComponent } from './auth/login/login.component';
|
||||||
import { RegisterComponent } from './auth/register/register.component';
|
import { SsoCallbackComponent } from './auth/sso-callback/sso-callback.component';
|
||||||
import { VerifyEmailComponent } from './auth/verify-email/verify-email.component';
|
|
||||||
import { ListDetailComponent } from './lists/list-detail/list-detail.component';
|
import { ListDetailComponent } from './lists/list-detail/list-detail.component';
|
||||||
import { ListsComponent } from './lists/lists.component';
|
import { ListsComponent } from './lists/lists.component';
|
||||||
import { TemplatesComponent } from './templates/templates.component';
|
import { TemplatesComponent } from './templates/templates.component';
|
||||||
@@ -12,12 +11,7 @@ import { TemplateDetailComponent } from './templates/template-detail/template-de
|
|||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
|
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
|
||||||
{ path: 'login', component: LoginComponent, canActivate: [unauthGuard] },
|
{ path: 'login', component: LoginComponent, canActivate: [unauthGuard] },
|
||||||
{ path: 'register', component: RegisterComponent, canActivate: [unauthGuard] },
|
{ path: 'auth/sso/callback', component: SsoCallbackComponent },
|
||||||
{ path: 'verify-email', component: VerifyEmailComponent, canActivate: [unauthGuard] },
|
|
||||||
{
|
|
||||||
path: 'auth',
|
|
||||||
children: [{ path: 'verify-email', component: VerifyEmailComponent }],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
|
|||||||
@@ -9,8 +9,16 @@
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
background:
|
background:
|
||||||
linear-gradient(140deg, color-mix(in srgb, var(--mat-sys-primary) 14%, transparent), transparent 38%),
|
linear-gradient(
|
||||||
linear-gradient(320deg, color-mix(in srgb, var(--mat-sys-tertiary) 12%, transparent), transparent 36%),
|
140deg,
|
||||||
|
color-mix(in srgb, var(--mat-sys-primary) 14%, transparent),
|
||||||
|
transparent 38%
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
320deg,
|
||||||
|
color-mix(in srgb, var(--mat-sys-tertiary) 12%, transparent),
|
||||||
|
transparent 36%
|
||||||
|
),
|
||||||
var(--mat-sys-surface-container);
|
var(--mat-sys-surface-container);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +29,9 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--mat-sys-surface);
|
background: var(--mat-sys-surface);
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||||
transition: box-shadow 0.3s ease, transform 0.3s ease;
|
transition:
|
||||||
|
box-shadow 0.3s ease,
|
||||||
|
transform 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card:hover {
|
.auth-card:hover {
|
||||||
@@ -54,7 +64,11 @@
|
|||||||
border: 1px solid color-mix(in srgb, var(--mat-sys-primary) 16%, transparent);
|
border: 1px solid color-mix(in srgb, var(--mat-sys-primary) 16%, transparent);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(145deg, color-mix(in srgb, var(--mat-sys-primary) 18%, transparent), transparent),
|
linear-gradient(
|
||||||
|
145deg,
|
||||||
|
color-mix(in srgb, var(--mat-sys-primary) 18%, transparent),
|
||||||
|
transparent
|
||||||
|
),
|
||||||
var(--mat-sys-surface-container-low);
|
var(--mat-sys-surface-container-low);
|
||||||
color: var(--mat-sys-primary);
|
color: var(--mat-sys-primary);
|
||||||
}
|
}
|
||||||
@@ -101,6 +115,18 @@
|
|||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sso-login-button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sso-login-button mat-progress-spinner {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-card mat-card-actions {
|
.auth-card mat-card-actions {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ export const authInterceptor: HttpInterceptorFn = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return auth.refreshSession().pipe(
|
return auth.refreshSession().pipe(
|
||||||
switchMap((response) =>
|
switchMap((response) => next(withAccessToken(request, response.accessToken))),
|
||||||
next(withAccessToken(request, response.accessToken)),
|
|
||||||
),
|
|
||||||
catchError((refreshError: unknown) => {
|
catchError((refreshError: unknown) => {
|
||||||
auth.logout();
|
auth.logout();
|
||||||
void router.navigateByUrl('/login');
|
void router.navigateByUrl('/login');
|
||||||
@@ -70,6 +68,7 @@ function isAuthRequest(request: HttpRequest<unknown>): boolean {
|
|||||||
return [
|
return [
|
||||||
'/api/auth/login',
|
'/api/auth/login',
|
||||||
'/api/auth/register',
|
'/api/auth/register',
|
||||||
|
'/api/auth/sso',
|
||||||
'/api/auth/refresh',
|
'/api/auth/refresh',
|
||||||
'/api/auth/resend-verification',
|
'/api/auth/resend-verification',
|
||||||
'/api/auth/verify-email',
|
'/api/auth/verify-email',
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ export interface PublicUser {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
verified: boolean;
|
|
||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
taskDigestPreference: TaskDigestPreference;
|
taskDigestPreference: TaskDigestPreference;
|
||||||
}
|
}
|
||||||
@@ -26,15 +25,6 @@ export interface RegisterResponse {
|
|||||||
user: PublicUser;
|
user: PublicUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VerifyEmailResponse {
|
|
||||||
message: string;
|
|
||||||
user: PublicUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResendVerificationResponse {
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import {
|
|||||||
PublicUserSearchResult,
|
PublicUserSearchResult,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
ResendVerificationResponse,
|
|
||||||
TaskDigestPreference,
|
TaskDigestPreference,
|
||||||
VerifyEmailResponse,
|
|
||||||
} from './auth.models';
|
} from './auth.models';
|
||||||
|
|
||||||
const ACCESS_TOKEN_KEY = 'listify.accessToken';
|
const ACCESS_TOKEN_KEY = 'listify.accessToken';
|
||||||
@@ -33,21 +31,26 @@ export class AuthService {
|
|||||||
.pipe(tap((response) => this.storeSession(response)));
|
.pipe(tap((response) => this.storeSession(response)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startSsoLogin(): void {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = `${this.apiUrl}/sso/login`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completeSsoLogin(response: AuthTokenResponse): void {
|
||||||
|
this.storeSession(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
exchangeSsoCode(code: string, state: string): Observable<AuthTokenResponse> {
|
||||||
|
return this.http
|
||||||
|
.post<AuthTokenResponse>(`${this.apiUrl}/sso/exchange`, { code, state })
|
||||||
|
.pipe(tap((response) => this.storeSession(response)));
|
||||||
|
}
|
||||||
|
|
||||||
register(data: RegisterRequest): Observable<RegisterResponse> {
|
register(data: RegisterRequest): Observable<RegisterResponse> {
|
||||||
return this.http.post<RegisterResponse>(`${this.apiUrl}/register`, data);
|
return this.http.post<RegisterResponse>(`${this.apiUrl}/register`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
verifyEmail(token: string): Observable<VerifyEmailResponse> {
|
|
||||||
const params = new HttpParams().set('token', token);
|
|
||||||
return this.http.get<VerifyEmailResponse>(`${this.apiUrl}/verify-email`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
resendVerificationEmail(email: string): Observable<ResendVerificationResponse> {
|
|
||||||
return this.http.post<ResendVerificationResponse>(`${this.apiUrl}/resend-verification`, {
|
|
||||||
email,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
loadCurrentUser(): Observable<PublicUser> {
|
loadCurrentUser(): Observable<PublicUser> {
|
||||||
return this.http.get<PublicUser>(`${this.apiUrl}/me`).pipe(tap((user) => this.storeUser(user)));
|
return this.http.get<PublicUser>(`${this.apiUrl}/me`).pipe(tap((user) => this.storeUser(user)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,81 +5,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>Willkommen zurueck</mat-card-title>
|
<mat-card-title>Willkommen zurueck</mat-card-title>
|
||||||
<mat-card-subtitle>Melden Sie sich mit Ihrem Listify-Konto an</mat-card-subtitle>
|
<mat-card-subtitle>Melden Sie sich mit Ihrem SSO-Konto an</mat-card-subtitle>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
|
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<form [formGroup]="form" (ngSubmit)="submit()" class="auth-form">
|
<button
|
||||||
<mat-form-field appearance="outline">
|
mat-flat-button
|
||||||
<mat-label>E-Mail</mat-label>
|
color="primary"
|
||||||
<input matInput type="email" formControlName="email" autocomplete="email" />
|
type="button"
|
||||||
<mat-icon matSuffix aria-hidden="true">mail</mat-icon>
|
class="sso-login-button"
|
||||||
@if (form.controls.email.hasError('required')) {
|
[disabled]="loading"
|
||||||
<mat-error>E-Mail ist erforderlich</mat-error>
|
(click)="loginWithSso()"
|
||||||
} @else if (form.controls.email.hasError('email')) {
|
>
|
||||||
<mat-error>Bitte geben Sie eine gueltige E-Mail ein</mat-error>
|
@if (loading) {
|
||||||
}
|
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||||
</mat-form-field>
|
} @else {
|
||||||
|
<mat-icon aria-hidden="true">login</mat-icon>
|
||||||
<mat-form-field appearance="outline">
|
}
|
||||||
<mat-label>Passwort</mat-label>
|
Mit SSO anmelden
|
||||||
<input
|
</button>
|
||||||
matInput
|
|
||||||
[type]="hidePassword ? 'password' : 'text'"
|
|
||||||
formControlName="password"
|
|
||||||
autocomplete="current-password"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
mat-icon-button
|
|
||||||
matSuffix
|
|
||||||
type="button"
|
|
||||||
[attr.aria-label]="hidePassword ? 'Passwort anzeigen' : 'Passwort verbergen'"
|
|
||||||
(click)="hidePassword = !hidePassword"
|
|
||||||
>
|
|
||||||
<mat-icon aria-hidden="true">{{ hidePassword ? 'visibility' : 'visibility_off' }}</mat-icon>
|
|
||||||
</button>
|
|
||||||
@if (form.controls.password.hasError('required')) {
|
|
||||||
<mat-error>Passwort ist erforderlich</mat-error>
|
|
||||||
} @else if (form.controls.password.hasError('minlength')) {
|
|
||||||
<mat-error>Mindestens 8 Zeichen</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<button mat-flat-button color="primary" type="submit" [disabled]="loading">
|
|
||||||
@if (loading) {
|
|
||||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
|
||||||
} @else {
|
|
||||||
<mat-icon aria-hidden="true">login</mat-icon>
|
|
||||||
}
|
|
||||||
Einloggen
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="divider-container">
|
|
||||||
<span class="divider-text">oder</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
mat-stroked-button
|
|
||||||
type="button"
|
|
||||||
[disabled]="resendingVerification || loading"
|
|
||||||
(click)="resendVerificationEmail()"
|
|
||||||
color="accent"
|
|
||||||
>
|
|
||||||
@if (resendingVerification) {
|
|
||||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
|
||||||
} @else {
|
|
||||||
<mat-icon aria-hidden="true">mark_email_unread</mat-icon>
|
|
||||||
}
|
|
||||||
Verifizierungsmail erneut senden
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
|
|
||||||
<mat-card-actions>
|
|
||||||
<span>Neu hier?</span>
|
|
||||||
<a mat-flat-button routerLink="/register" color="primary" class="register-link">
|
|
||||||
Konto erstellen
|
|
||||||
</a>
|
|
||||||
</mat-card-actions>
|
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,45 +1,21 @@
|
|||||||
import { Component, inject, OnInit } from '@angular/core';
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { Router } from '@angular/router';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
|
||||||
import { finalize } from 'rxjs';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
|
||||||
import { AuthService } from '../auth.service';
|
import { AuthService } from '../auth.service';
|
||||||
import { getAuthErrorMessage } from '../error-message';
|
|
||||||
import { OnboardingService } from '../../onboarding/onboarding.service';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-login',
|
selector: 'app-login',
|
||||||
imports: [
|
imports: [MatButtonModule, MatCardModule, MatIconModule, MatProgressSpinnerModule],
|
||||||
ReactiveFormsModule,
|
|
||||||
RouterLink,
|
|
||||||
MatButtonModule,
|
|
||||||
MatCardModule,
|
|
||||||
MatFormFieldModule,
|
|
||||||
MatIconModule,
|
|
||||||
MatInputModule,
|
|
||||||
MatProgressSpinnerModule,
|
|
||||||
MatSnackBarModule,
|
|
||||||
],
|
|
||||||
templateUrl: './login.component.html',
|
templateUrl: './login.component.html',
|
||||||
styleUrl: '../auth-page.scss',
|
styleUrl: '../auth-page.scss',
|
||||||
})
|
})
|
||||||
export class LoginComponent implements OnInit {
|
export class LoginComponent implements OnInit {
|
||||||
private readonly auth = inject(AuthService);
|
private readonly auth = inject(AuthService);
|
||||||
private readonly formBuilder = inject(NonNullableFormBuilder);
|
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly snackBar = inject(MatSnackBar);
|
|
||||||
private readonly onboarding = inject(OnboardingService);
|
|
||||||
|
|
||||||
protected readonly form = this.formBuilder.group({
|
|
||||||
email: ['', [Validators.required, Validators.email]],
|
|
||||||
password: ['', [Validators.required, Validators.minLength(8)]],
|
|
||||||
});
|
|
||||||
protected loading = false;
|
protected loading = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -47,51 +23,9 @@ export class LoginComponent implements OnInit {
|
|||||||
void this.router.navigateByUrl('/lists');
|
void this.router.navigateByUrl('/lists');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
protected resendingVerification = false;
|
|
||||||
protected hidePassword = true;
|
|
||||||
|
|
||||||
submit(): void {
|
|
||||||
if (this.form.invalid) {
|
|
||||||
this.form.markAllAsTouched();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
loginWithSso(): void {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.auth
|
this.auth.startSsoLogin();
|
||||||
.login(this.form.getRawValue())
|
|
||||||
.pipe(finalize(() => (this.loading = false)))
|
|
||||||
.subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.snackBar.open('Login erfolgreich.', 'OK', { duration: 3000 });
|
|
||||||
if (!this.onboarding.startForCurrentUser()) {
|
|
||||||
void this.router.navigateByUrl('/account');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: (error: unknown) => {
|
|
||||||
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
resendVerificationEmail(): void {
|
|
||||||
const emailControl = this.form.controls.email;
|
|
||||||
|
|
||||||
if (emailControl.invalid) {
|
|
||||||
emailControl.markAsTouched();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.resendingVerification = true;
|
|
||||||
this.auth
|
|
||||||
.resendVerificationEmail(emailControl.value)
|
|
||||||
.pipe(finalize(() => (this.resendingVerification = false)))
|
|
||||||
.subscribe({
|
|
||||||
next: (response) => {
|
|
||||||
this.snackBar.open(response.message, 'OK', { duration: 6000 });
|
|
||||||
},
|
|
||||||
error: (error: unknown) => {
|
|
||||||
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-header>
|
||||||
|
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="verification-state" [class.success]="state() === 'success'" [class.error]="state() === 'error' || state() === 'missing-token'">
|
<div class="verification-state success">
|
||||||
@if (state() === 'loading') {
|
<mat-icon class="state-icon" aria-hidden="true">mark_email_read</mat-icon>
|
||||||
<mat-progress-spinner mode="indeterminate" diameter="44" />
|
|
||||||
} @else if (state() === 'success') {
|
|
||||||
<mat-icon class="state-icon" aria-hidden="true">mark_email_read</mat-icon>
|
|
||||||
} @else {
|
|
||||||
<mat-icon class="state-icon" aria-hidden="true">error</mat-icon>
|
|
||||||
}
|
|
||||||
|
|
||||||
<p>{{ message() }}</p>
|
<p>{{ message() }}</p>
|
||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
|
|
||||||
<mat-card-actions align="end">
|
<mat-card-actions align="end">
|
||||||
@if (state() === 'success') {
|
<a mat-flat-button routerLink="/login">
|
||||||
<a mat-flat-button routerLink="/login">
|
<mat-icon aria-hidden="true">login</mat-icon>
|
||||||
<mat-icon aria-hidden="true">login</mat-icon>
|
Zum Login
|
||||||
Zum Login
|
</a>
|
||||||
</a>
|
|
||||||
} @else if (state() !== 'loading') {
|
|
||||||
<a mat-button routerLink="/register">Neu registrieren</a>
|
|
||||||
<a mat-flat-button routerLink="/login">Zum Login</a>
|
|
||||||
}
|
|
||||||
</mat-card-actions>
|
</mat-card-actions>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
||||||
import { AuthService } from '../auth.service';
|
import { AuthService } from '../auth.service';
|
||||||
import { getAuthErrorMessage } from '../error-message';
|
|
||||||
|
|
||||||
type VerificationState = 'loading' | 'success' | 'error' | 'missing-token';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-verify-email',
|
selector: 'app-verify-email',
|
||||||
@@ -16,18 +12,15 @@ type VerificationState = 'loading' | 'success' | 'error' | 'missing-token';
|
|||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatProgressSpinnerModule,
|
|
||||||
],
|
],
|
||||||
templateUrl: './verify-email.component.html',
|
templateUrl: './verify-email.component.html',
|
||||||
styleUrl: '../auth-page.scss',
|
styleUrl: '../auth-page.scss',
|
||||||
})
|
})
|
||||||
export class VerifyEmailComponent implements OnInit {
|
export class VerifyEmailComponent implements OnInit {
|
||||||
private readonly auth = inject(AuthService);
|
private readonly auth = inject(AuthService);
|
||||||
private readonly route = inject(ActivatedRoute);
|
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
protected readonly state = signal<VerificationState>('loading');
|
protected readonly message = signal('E-Mail-Verifikation ist nicht mehr erforderlich.');
|
||||||
protected readonly message = signal('E-Mail wird bestätigt.');
|
|
||||||
protected readonly email = signal<string | null>(null);
|
protected readonly email = signal<string | null>(null);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -35,25 +28,5 @@ export class VerifyEmailComponent implements OnInit {
|
|||||||
void this.router.navigateByUrl('/lists');
|
void this.router.navigateByUrl('/lists');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = this.route.snapshot.queryParamMap.get('token');
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
this.state.set('missing-token');
|
|
||||||
this.message.set('Der Verifikationslink enthält keinen Token.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.auth.verifyEmail(token).subscribe({
|
|
||||||
next: (response) => {
|
|
||||||
this.email.set(response.user.email);
|
|
||||||
this.message.set(response.message);
|
|
||||||
this.state.set('success');
|
|
||||||
},
|
|
||||||
error: (error: unknown) => {
|
|
||||||
this.message.set(getAuthErrorMessage(error));
|
|
||||||
this.state.set('error');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user