idp angepasst
This commit is contained in:
@@ -18,11 +18,12 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
|
||||
# Browser-URL, unter der der Container erreichbar ist.
|
||||
CLIENT_URL=http://localhost:8080
|
||||
|
||||
OIDC_ISSUER_URL=https://auth.forgecore.work/realms/listify
|
||||
OIDC_DISCOVERY_URL=
|
||||
OIDC_ISSUER=https://id.example.com
|
||||
OIDC_CLIENT_ID=listify
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_CALLBACK_URL=http://localhost:8080/auth/sso/callback
|
||||
OIDC_SCOPES=openid profile email groups
|
||||
OIDC_REDIRECT_URI=http://localhost:8080/auth/sso/callback
|
||||
OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:8080/login
|
||||
|
||||
MISTRAL_API_KEY=
|
||||
MISTRAL_AGENT_ID=
|
||||
|
||||
@@ -15,11 +15,12 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
|
||||
|
||||
CLIENT_URL=http://localhost:4200
|
||||
|
||||
OIDC_ISSUER_URL=https://auth.forgecore.work/realms/Homelab/account
|
||||
OIDC_DISCOVERY_URL=
|
||||
OIDC_ISSUER=https://id.example.com
|
||||
OIDC_CLIENT_ID=listify
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_CALLBACK_URL=http://localhost:4200/auth/sso/callback
|
||||
OIDC_SCOPES=openid profile email groups
|
||||
OIDC_REDIRECT_URI=http://localhost:4200/auth/sso/callback
|
||||
OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/login
|
||||
|
||||
MCP_ACCESS_TOKEN=
|
||||
|
||||
|
||||
@@ -60,17 +60,21 @@ Configure the external MCP connector with `Authorization: Bearer $MCP_ACCESS_TOK
|
||||
|
||||
Every Mistral response is stored in `assistant_chat_logs`. The table includes the sanitized provider request, the full raw provider response, the extracted assistant text sent back to the UI, response status and timing metadata.
|
||||
|
||||
## SSO mit Keycloak
|
||||
## SSO mit OIDC
|
||||
|
||||
Listify nutzt OpenID Connect mit Authorization Code + PKCE. Bei Keycloak muss der Issuer immer auf den Realm zeigen, nicht nur auf die Basisdomain.
|
||||
Listify nutzt OpenID Connect mit Authorization Code Flow und PKCE (`S256`). Die Discovery-URL wird automatisch aus dem Issuer gebildet:
|
||||
|
||||
### Keycloak Client
|
||||
```text
|
||||
{OIDC_ISSUER}/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
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:
|
||||
### LDAP-Portal Client
|
||||
|
||||
1. Im LDAP-Portal unter `/admin/oidc-clients` einen Client fuer Listify registrieren.
|
||||
2. Authorization Code Flow mit PKCE aktivieren. Dynamic Client Registration wird nicht verwendet.
|
||||
3. Scopes `openid profile email groups` erlauben. Fuer Refresh Tokens optional `offline_access` ergaenzen.
|
||||
4. Der Client muss `sub`, `preferred_username`, `email`, `name`, `given_name`, `family_name` und bei Scope `groups` den Claim `groups` erhalten.
|
||||
5. Redirect URI registrieren:
|
||||
|
||||
```text
|
||||
http://localhost:4200/auth/sso/callback
|
||||
@@ -84,30 +88,43 @@ 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`.
|
||||
|
||||
6. Post-Logout Redirect URI registrieren:
|
||||
|
||||
```text
|
||||
http://localhost:4200/login
|
||||
```
|
||||
|
||||
Bei Docker/Reverse Proxy:
|
||||
|
||||
```text
|
||||
http://localhost:8080/login
|
||||
```
|
||||
|
||||
### 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
|
||||
OIDC_ISSUER=https://id.example.com
|
||||
OIDC_CLIENT_ID=<client-id-aus-admin-oidc-clients>
|
||||
OIDC_CLIENT_SECRET=<client-secret-aus-admin-oidc-clients>
|
||||
OIDC_SCOPES=openid profile email groups
|
||||
OIDC_REDIRECT_URI=http://localhost:4200/auth/sso/callback
|
||||
OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/login
|
||||
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:
|
||||
Das ID Token wird per JWKS validiert. Das Access Token des LDAP-Portals ist opaque; Listify validiert es ueber `/oidc/token/introspection` und ruft danach `/oidc/me` mit `Authorization: Bearer <access_token>` fuer UserInfo auf.
|
||||
|
||||
```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:
|
||||
Wenn die Introspection-Antwort eine andere Access-Token-Audience als die Client-ID enthaelt, kann sie explizit gesetzt werden:
|
||||
|
||||
```bash
|
||||
OIDC_ISSUER_URL=https://auth.forgecore.work/realms/listify
|
||||
OIDC_DISCOVERY_URL=https://auth.forgecore.work/realms/listify/.well-known/openid-configuration
|
||||
OIDC_ACCESS_TOKEN_AUDIENCE=<expected-access-token-audience>
|
||||
```
|
||||
|
||||
Gruppen werden aus dem Claim `groups` gelesen und lokal auf App-Rollen gemappt. Das Mapping ist zentral in `oidc_group_role_mappings` konfigurierbar, z. B.:
|
||||
|
||||
```sql
|
||||
INSERT INTO oidc_group_role_mappings (id, groupPath, role, enabled)
|
||||
VALUES (UUID(), '/teams/admins', 'app_admin', 1);
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('assistant_chat_logs')
|
||||
export class AssistantChatLogEntity {
|
||||
|
||||
@@ -8,16 +8,19 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../auth/permissions.guard';
|
||||
import { RequirePermissions } from '../auth/require-permissions.decorator';
|
||||
import { AssistantService } from './assistant.service';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.types';
|
||||
import type { AssistantChatRequest } from './assistant.types';
|
||||
|
||||
@Controller('assistant')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class AssistantController {
|
||||
constructor(private readonly assistantService: AssistantService) {}
|
||||
|
||||
@Get('chat/logs')
|
||||
@RequirePermissions('assistant.logs.view')
|
||||
listChatLogs(@Req() request: AuthenticatedRequest) {
|
||||
const userId = request.user?.sub;
|
||||
|
||||
@@ -29,6 +32,7 @@ export class AssistantController {
|
||||
}
|
||||
|
||||
@Post('chat')
|
||||
@RequirePermissions('assistant.chat')
|
||||
chat(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() body: AssistantChatRequest,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('audit_logs')
|
||||
export class AuditLogEntity {
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Repository } from 'typeorm';
|
||||
import { AuditLogEntity } from './audit-log.entity';
|
||||
import type { AuditLogInput } from './audit-log.types';
|
||||
|
||||
const SENSITIVE_KEY_PATTERN = /password|token|secret|authorization|cookie|hash/i;
|
||||
const SENSITIVE_KEY_PATTERN =
|
||||
/password|token|secret|authorization|cookie|hash/i;
|
||||
|
||||
@Injectable()
|
||||
export class AuditLogService {
|
||||
@@ -60,7 +61,9 @@ export class AuditLogService {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
|
||||
key,
|
||||
SENSITIVE_KEY_PATTERN.test(key) ? '[redacted]' : this.sanitizeNestedValue(entry),
|
||||
SENSITIVE_KEY_PATTERN.test(key)
|
||||
? '[redacted]'
|
||||
: this.sanitizeNestedValue(entry),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
20
listify-api/src/auth/app-permission.entity.ts
Normal file
20
listify-api/src/auth/app-permission.entity.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity('app_permissions')
|
||||
export class AppPermissionEntity {
|
||||
@PrimaryColumn({ type: 'varchar', length: 120 })
|
||||
permission!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 160 })
|
||||
label!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'datetime',
|
||||
precision: 3,
|
||||
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||
})
|
||||
createdAt!: Date;
|
||||
}
|
||||
31
listify-api/src/auth/app-role-permission.entity.ts
Normal file
31
listify-api/src/auth/app-role-permission.entity.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('app_role_permissions')
|
||||
@Index('IDX_app_role_permissions_role_permission', ['role', 'permission'], {
|
||||
unique: true,
|
||||
})
|
||||
export class AppRolePermissionEntity {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'varchar', length: 80 })
|
||||
role!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'varchar', length: 120 })
|
||||
permission!: string;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'datetime',
|
||||
precision: 3,
|
||||
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||
})
|
||||
createdAt!: Date;
|
||||
}
|
||||
20
listify-api/src/auth/app-role.entity.ts
Normal file
20
listify-api/src/auth/app-role.entity.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity('app_roles')
|
||||
export class AppRoleEntity {
|
||||
@PrimaryColumn({ type: 'varchar', length: 80 })
|
||||
role!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 160 })
|
||||
label!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'datetime',
|
||||
precision: 3,
|
||||
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||
})
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -66,6 +66,10 @@ export class AuthController {
|
||||
user: JSON.stringify(authResponse.user),
|
||||
});
|
||||
|
||||
if (authResponse.idToken) {
|
||||
fragment.set('idToken', authResponse.idToken);
|
||||
}
|
||||
|
||||
response.redirect(`${redirectUrl.toString()}#${fragment.toString()}`);
|
||||
}
|
||||
|
||||
@@ -80,6 +84,12 @@ export class AuthController {
|
||||
return this.authService.refresh(refreshTokenDto);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
logout(@Body() body: { refreshToken?: string; idTokenHint?: string }) {
|
||||
return this.authService.logout(body);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@Req() request: AuthenticatedRequest) {
|
||||
|
||||
@@ -2,13 +2,19 @@ import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AppPermissionEntity } from './app-permission.entity';
|
||||
import { AppRolePermissionEntity } from './app-role-permission.entity';
|
||||
import { AppRoleEntity } from './app-role.entity';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthzSeedService } from './authz-seed.service';
|
||||
import { OidcGroupRoleMappingEntity } from './oidc-group-role-mapping.entity';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { McpAuthGuard } from './mcp-auth.guard';
|
||||
import { OidcService } from './oidc.service';
|
||||
import { UserKeycloakGroupEntity } from './user-keycloak-group.entity';
|
||||
import { PermissionsGuard } from './permissions.guard';
|
||||
import { UserOidcGroupEntity } from './user-oidc-group.entity';
|
||||
import { UserImpersonationEntity } from './user-impersonation.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
@@ -19,12 +25,23 @@ import { UserEntity } from './user.entity';
|
||||
TypeOrmModule.forFeature([
|
||||
UserEntity,
|
||||
RefreshTokenEntity,
|
||||
UserKeycloakGroupEntity,
|
||||
UserOidcGroupEntity,
|
||||
UserImpersonationEntity,
|
||||
AppRoleEntity,
|
||||
AppPermissionEntity,
|
||||
AppRolePermissionEntity,
|
||||
OidcGroupRoleMappingEntity,
|
||||
]),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, OidcService, JwtAuthGuard, McpAuthGuard],
|
||||
exports: [AuthService, JwtAuthGuard, McpAuthGuard],
|
||||
providers: [
|
||||
AuthService,
|
||||
AuthzSeedService,
|
||||
OidcService,
|
||||
JwtAuthGuard,
|
||||
McpAuthGuard,
|
||||
PermissionsGuard,
|
||||
],
|
||||
exports: [AuthService, JwtAuthGuard, McpAuthGuard, PermissionsGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -2,11 +2,14 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { JwtModule, JwtService } from '@nestjs/jwt';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { AppRolePermissionEntity } from './app-role-permission.entity';
|
||||
import { AuthTokenResponse, JwtTokenPayload } from './auth.types';
|
||||
import { AuthService } from './auth.service';
|
||||
import { DEFAULT_APP_ROLE } from './authz.constants';
|
||||
import { OidcGroupRoleMappingEntity } from './oidc-group-role-mapping.entity';
|
||||
import { OidcProfile, OidcService } from './oidc.service';
|
||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||
import { UserKeycloakGroupEntity } from './user-keycloak-group.entity';
|
||||
import { UserOidcGroupEntity } from './user-oidc-group.entity';
|
||||
import { UserImpersonationEntity } from './user-impersonation.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
import { InMemoryRepository } from '../testing/in-memory-repository';
|
||||
@@ -17,12 +20,16 @@ class FakeOidcService {
|
||||
email: 'User@Example.com',
|
||||
name: 'Test User',
|
||||
groups: [],
|
||||
idToken: 'id-token',
|
||||
};
|
||||
|
||||
createAuthorizationUrl = jest.fn(
|
||||
async () => 'https://sso.example.test/authorize',
|
||||
createAuthorizationUrl = jest.fn(() =>
|
||||
Promise.resolve('https://sso.example.test/authorize'),
|
||||
);
|
||||
exchangeCallback = jest.fn(() => Promise.resolve(this.profile));
|
||||
createLogoutUrl = jest.fn(() =>
|
||||
Promise.resolve('https://sso.example.test/logout'),
|
||||
);
|
||||
exchangeCallback = jest.fn(async () => this.profile);
|
||||
}
|
||||
|
||||
describe('AuthService', () => {
|
||||
@@ -31,16 +38,21 @@ describe('AuthService', () => {
|
||||
let jwtService: JwtService;
|
||||
let oidcService: FakeOidcService;
|
||||
let usersRepository: InMemoryRepository<UserEntity>;
|
||||
let userKeycloakGroupsRepository: InMemoryRepository<UserKeycloakGroupEntity>;
|
||||
let userOidcGroupsRepository: InMemoryRepository<UserOidcGroupEntity>;
|
||||
let userImpersonationsRepository: InMemoryRepository<UserImpersonationEntity>;
|
||||
let appRolePermissionsRepository: InMemoryRepository<AppRolePermissionEntity>;
|
||||
let oidcGroupRoleMappingsRepository: InMemoryRepository<OidcGroupRoleMappingEntity>;
|
||||
|
||||
beforeEach(async () => {
|
||||
oidcService = new FakeOidcService();
|
||||
usersRepository = new InMemoryRepository<UserEntity>();
|
||||
userKeycloakGroupsRepository =
|
||||
new InMemoryRepository<UserKeycloakGroupEntity>();
|
||||
userOidcGroupsRepository = new InMemoryRepository<UserOidcGroupEntity>();
|
||||
userImpersonationsRepository =
|
||||
new InMemoryRepository<UserImpersonationEntity>();
|
||||
appRolePermissionsRepository =
|
||||
new InMemoryRepository<AppRolePermissionEntity>();
|
||||
oidcGroupRoleMappingsRepository =
|
||||
new InMemoryRepository<OidcGroupRoleMappingEntity>();
|
||||
module = await Test.createTestingModule({
|
||||
imports: [EventEmitterModule.forRoot(), JwtModule.register({})],
|
||||
providers: [
|
||||
@@ -58,19 +70,28 @@ describe('AuthService', () => {
|
||||
useValue: new InMemoryRepository<RefreshTokenEntity>(),
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserKeycloakGroupEntity),
|
||||
useValue: userKeycloakGroupsRepository,
|
||||
provide: getRepositoryToken(UserOidcGroupEntity),
|
||||
useValue: userOidcGroupsRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserImpersonationEntity),
|
||||
useValue: userImpersonationsRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AppRolePermissionEntity),
|
||||
useValue: appRolePermissionsRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(OidcGroupRoleMappingEntity),
|
||||
useValue: oidcGroupRoleMappingsRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
await module.init();
|
||||
|
||||
authService = module.get<AuthService>(AuthService);
|
||||
jwtService = module.get<JwtService>(JwtService);
|
||||
await seedRolePermissions();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -91,6 +112,9 @@ describe('AuthService', () => {
|
||||
expect(loginResponse.refreshToken).toBeDefined();
|
||||
expect(loginResponse.user.email).toBe('user@example.com');
|
||||
expect(loginResponse.user.name).toBe('Test User');
|
||||
expect(loginResponse.user.roles).toEqual([DEFAULT_APP_ROLE]);
|
||||
expect(loginResponse.user.permissions).toContain('assistant.chat');
|
||||
expect(loginResponse.user.permissions).not.toContain('assistant.logs.view');
|
||||
expect(oidcService.exchangeCallback).toHaveBeenCalledWith('code', 'state');
|
||||
});
|
||||
|
||||
@@ -101,6 +125,7 @@ describe('AuthService', () => {
|
||||
email: 'renamed@example.com',
|
||||
name: 'Renamed User',
|
||||
groups: [],
|
||||
idToken: 'id-token',
|
||||
};
|
||||
|
||||
const secondLogin = await authService.completeSsoLogin('code', 'state');
|
||||
@@ -117,6 +142,7 @@ describe('AuthService', () => {
|
||||
email: 'User@Example.com',
|
||||
name: 'Linked User',
|
||||
groups: [],
|
||||
idToken: 'id-token',
|
||||
};
|
||||
|
||||
const secondLogin = await authService.completeSsoLogin('code', 'state');
|
||||
@@ -150,7 +176,43 @@ describe('AuthService', () => {
|
||||
expect(refreshPayload.jti).toBeDefined();
|
||||
});
|
||||
|
||||
it('syncs Keycloak groups from the SSO profile', async () => {
|
||||
it('revokes local refresh tokens and returns the OIDC logout URL', async () => {
|
||||
const loginResponse = await authService.completeSsoLogin('code', 'state');
|
||||
const logoutResponse = await authService.logout({
|
||||
refreshToken: loginResponse.refreshToken,
|
||||
idTokenHint: loginResponse.idToken,
|
||||
});
|
||||
|
||||
expect(logoutResponse.logoutUrl).toBe('https://sso.example.test/logout');
|
||||
expect(oidcService.createLogoutUrl).toHaveBeenCalledWith('id-token');
|
||||
await expect(
|
||||
authService.refresh({ refreshToken: loginResponse.refreshToken }),
|
||||
).rejects.toThrow('Refresh token is invalid.');
|
||||
});
|
||||
|
||||
it('maps synchronized OIDC groups to app roles and permissions', async () => {
|
||||
oidcService.profile.groups = ['/teams/admins'];
|
||||
await oidcGroupRoleMappingsRepository.save(
|
||||
oidcGroupRoleMappingsRepository.create({
|
||||
id: 'mapping-admins-admin',
|
||||
groupPath: '/teams/admins',
|
||||
role: 'app_admin',
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const loginResponse = await authService.completeSsoLogin('code', 'state');
|
||||
const payload = await authService.verifyAccessToken(
|
||||
loginResponse.accessToken,
|
||||
);
|
||||
|
||||
expect(loginResponse.user.roles).toEqual(['app_admin', 'app_user']);
|
||||
expect(loginResponse.user.permissions).toContain('assistant.logs.view');
|
||||
expect(payload.roles).toEqual(['app_admin', 'app_user']);
|
||||
expect(payload.permissions).toContain('assistant.logs.view');
|
||||
});
|
||||
|
||||
it('syncs OIDC groups from the SSO profile', async () => {
|
||||
oidcService.profile.groups = [
|
||||
'/teams/engineering',
|
||||
'/teams/admins',
|
||||
@@ -169,13 +231,14 @@ describe('AuthService', () => {
|
||||
email: 'user@example.com',
|
||||
name: 'Test User',
|
||||
groups: ['/teams/support'],
|
||||
idToken: 'id-token',
|
||||
};
|
||||
|
||||
const secondLoginResponse = await authService.completeSsoLogin(
|
||||
'code',
|
||||
'state',
|
||||
);
|
||||
const storedGroups = await userKeycloakGroupsRepository.find({
|
||||
const storedGroups = await userOidcGroupsRepository.find({
|
||||
where: { userId: secondLoginResponse.user.id },
|
||||
});
|
||||
|
||||
@@ -326,4 +389,36 @@ describe('AuthService', () => {
|
||||
}),
|
||||
)) as UserEntity;
|
||||
}
|
||||
|
||||
async function seedRolePermissions(): Promise<void> {
|
||||
await appRolePermissionsRepository.save([
|
||||
rolePermission('rp-user-dashboard', 'app_user', 'dashboard.view'),
|
||||
rolePermission('rp-user-lists', 'app_user', 'lists.manage_own'),
|
||||
rolePermission('rp-user-templates', 'app_user', 'templates.manage_own'),
|
||||
rolePermission('rp-user-tasks', 'app_user', 'tasks.manage_own'),
|
||||
rolePermission('rp-user-assistant-chat', 'app_user', 'assistant.chat'),
|
||||
rolePermission('rp-user-account', 'app_user', 'account.manage_self'),
|
||||
rolePermission('rp-user-search', 'app_user', 'users.search'),
|
||||
rolePermission('rp-admin-dashboard', 'app_admin', 'dashboard.view'),
|
||||
rolePermission('rp-admin-lists', 'app_admin', 'lists.manage_own'),
|
||||
rolePermission('rp-admin-templates', 'app_admin', 'templates.manage_own'),
|
||||
rolePermission('rp-admin-tasks', 'app_admin', 'tasks.manage_own'),
|
||||
rolePermission('rp-admin-assistant-chat', 'app_admin', 'assistant.chat'),
|
||||
rolePermission(
|
||||
'rp-admin-assistant-logs',
|
||||
'app_admin',
|
||||
'assistant.logs.view',
|
||||
),
|
||||
rolePermission('rp-admin-account', 'app_admin', 'account.manage_self'),
|
||||
rolePermission('rp-admin-search', 'app_admin', 'users.search'),
|
||||
]);
|
||||
}
|
||||
|
||||
function rolePermission(
|
||||
id: string,
|
||||
role: string,
|
||||
permission: string,
|
||||
): AppRolePermissionEntity {
|
||||
return appRolePermissionsRepository.create({ id, role, permission });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,26 +8,35 @@ import {
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto';
|
||||
import { Like, Repository } from 'typeorm';
|
||||
import { In, Like, Repository } from 'typeorm';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { AppRolePermissionEntity } from './app-role-permission.entity';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { ResendVerificationDto } from './dto/resend-verification.dto';
|
||||
import {
|
||||
AuthLogoutResponse,
|
||||
AuthTokenResponse,
|
||||
AuthTokens,
|
||||
JwtTokenPayload,
|
||||
PublicUser,
|
||||
PublicUserSearchResult,
|
||||
} from './auth.types';
|
||||
import { DEFAULT_APP_ROLE } from './authz.constants';
|
||||
import type { TaskDigestPreference } from '../tasks/task-digest.types';
|
||||
import { OidcGroupRoleMappingEntity } from './oidc-group-role-mapping.entity';
|
||||
import { OidcProfile, OidcService } from './oidc.service';
|
||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||
import { UserKeycloakGroupEntity } from './user-keycloak-group.entity';
|
||||
import { UserOidcGroupEntity } from './user-oidc-group.entity';
|
||||
import { UserImpersonationEntity } from './user-impersonation.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
interface UserAuthorization {
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly accessTokenExpiresIn = '7d';
|
||||
@@ -44,38 +53,46 @@ export class AuthService {
|
||||
private readonly usersRepository: Repository<UserEntity>,
|
||||
@InjectRepository(RefreshTokenEntity)
|
||||
private readonly refreshTokensRepository: Repository<RefreshTokenEntity>,
|
||||
@InjectRepository(UserKeycloakGroupEntity)
|
||||
private readonly userKeycloakGroupsRepository: Repository<UserKeycloakGroupEntity>,
|
||||
@InjectRepository(UserOidcGroupEntity)
|
||||
private readonly userOidcGroupsRepository: Repository<UserOidcGroupEntity>,
|
||||
@InjectRepository(UserImpersonationEntity)
|
||||
private readonly userImpersonationsRepository: Repository<UserImpersonationEntity>,
|
||||
@InjectRepository(AppRolePermissionEntity)
|
||||
private readonly appRolePermissionsRepository: Repository<AppRolePermissionEntity>,
|
||||
@InjectRepository(OidcGroupRoleMappingEntity)
|
||||
private readonly oidcGroupRoleMappingsRepository: Repository<OidcGroupRoleMappingEntity>,
|
||||
@Optional()
|
||||
private readonly auditLogService?: AuditLogService,
|
||||
) {}
|
||||
|
||||
async register(
|
||||
register(
|
||||
registerDto: RegisterDto,
|
||||
): Promise<{ message: string; user: PublicUser }> {
|
||||
void registerDto;
|
||||
throw new GoneException('Registration is handled by the SSO provider.');
|
||||
return Promise.reject(
|
||||
new GoneException('Registration is handled by the SSO provider.'),
|
||||
);
|
||||
}
|
||||
|
||||
async verifyEmail(
|
||||
token?: string,
|
||||
): Promise<{ message: string; user: PublicUser }> {
|
||||
verifyEmail(token?: string): Promise<{ message: string; user: PublicUser }> {
|
||||
void token;
|
||||
throw new GoneException('Email verification is no longer required.');
|
||||
return Promise.reject(
|
||||
new GoneException('Email verification is no longer required.'),
|
||||
);
|
||||
}
|
||||
|
||||
async resendVerificationEmail(
|
||||
resendVerificationEmail(
|
||||
resendVerificationDto: ResendVerificationDto,
|
||||
): Promise<{ message: string }> {
|
||||
void resendVerificationDto;
|
||||
throw new GoneException('Email verification is no longer required.');
|
||||
return Promise.reject(
|
||||
new GoneException('Email verification is no longer required.'),
|
||||
);
|
||||
}
|
||||
|
||||
async login(loginDto: LoginDto): Promise<AuthTokenResponse> {
|
||||
login(loginDto: LoginDto): Promise<AuthTokenResponse> {
|
||||
void loginDto;
|
||||
throw new GoneException('Login is handled by SSO.');
|
||||
return Promise.reject(new GoneException('Login is handled by SSO.'));
|
||||
}
|
||||
|
||||
startSsoLogin(): Promise<string> {
|
||||
@@ -86,7 +103,9 @@ export class AuthService {
|
||||
code?: string,
|
||||
state?: string,
|
||||
): Promise<AuthTokenResponse> {
|
||||
console.log("completesso")
|
||||
const profile = await this.oidcService.exchangeCallback(code, state);
|
||||
console.log(profile)
|
||||
const existingUser =
|
||||
(await this.usersRepository.findOne({
|
||||
where: { oidcSubject: profile.subject },
|
||||
@@ -95,10 +114,11 @@ export class AuthService {
|
||||
where: { email: this.normalizeEmail(profile.email) },
|
||||
}));
|
||||
const user = await this.syncOidcUser(profile, existingUser);
|
||||
await this.syncKeycloakGroups(user.id, profile.groups);
|
||||
await this.syncOidcGroups(user.id, profile.groups);
|
||||
const response = {
|
||||
...(await this.createAuthTokens(user)),
|
||||
user: await this.toPublicUserWithGroups(
|
||||
idToken: profile.idToken,
|
||||
user: await this.toPublicUserWithAuthorization(
|
||||
await this.resolveEffectiveUser(user),
|
||||
),
|
||||
};
|
||||
@@ -115,6 +135,19 @@ export class AuthService {
|
||||
return response;
|
||||
}
|
||||
|
||||
async logout(
|
||||
body: {
|
||||
refreshToken?: string;
|
||||
idTokenHint?: string;
|
||||
} = {},
|
||||
): Promise<AuthLogoutResponse> {
|
||||
await this.revokeRefreshToken(body.refreshToken);
|
||||
|
||||
return {
|
||||
logoutUrl: await this.oidcService.createLogoutUrl(body.idTokenHint),
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(
|
||||
refreshTokenDto: RefreshTokenDto = {},
|
||||
): Promise<AuthTokenResponse> {
|
||||
@@ -145,7 +178,7 @@ export class AuthService {
|
||||
|
||||
const response = {
|
||||
...(await this.createAuthTokens(user)),
|
||||
user: await this.toPublicUserWithGroups(
|
||||
user: await this.toPublicUserWithAuthorization(
|
||||
await this.resolveEffectiveUser(user),
|
||||
),
|
||||
};
|
||||
@@ -180,15 +213,23 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const effectiveUser = await this.resolveEffectiveUser(user);
|
||||
|
||||
if (effectiveUser.id === user.id) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return {
|
||||
const authorization = await this.resolveUserAuthorization(
|
||||
effectiveUser.id,
|
||||
);
|
||||
const authenticatedPayload: JwtTokenPayload = {
|
||||
...payload,
|
||||
sub: effectiveUser.id,
|
||||
email: effectiveUser.email,
|
||||
roles: authorization.roles,
|
||||
permissions: authorization.permissions,
|
||||
};
|
||||
|
||||
if (effectiveUser.id === user.id) {
|
||||
return authenticatedPayload;
|
||||
}
|
||||
|
||||
return {
|
||||
...authenticatedPayload,
|
||||
impersonatorSub: user.id,
|
||||
impersonatorEmail: user.email,
|
||||
};
|
||||
@@ -218,7 +259,7 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Authenticated user is required.');
|
||||
}
|
||||
|
||||
return this.toPublicUserWithGroups(user);
|
||||
return this.toPublicUserWithAuthorization(user);
|
||||
}
|
||||
|
||||
async searchUsers(
|
||||
@@ -278,7 +319,7 @@ export class AuthService {
|
||||
metadata: { completed },
|
||||
});
|
||||
|
||||
return this.toPublicUserWithGroups(savedUser);
|
||||
return this.toPublicUserWithAuthorization(savedUser);
|
||||
}
|
||||
|
||||
async updateTaskDigestPreference(
|
||||
@@ -306,7 +347,7 @@ export class AuthService {
|
||||
metadata: { taskDigestPreference: savedUser.taskDigestPreference },
|
||||
});
|
||||
|
||||
return this.toPublicUserWithGroups(savedUser);
|
||||
return this.toPublicUserWithAuthorization(savedUser);
|
||||
}
|
||||
|
||||
private normalizeEmail(email?: string): string {
|
||||
@@ -382,21 +423,21 @@ export class AuthService {
|
||||
return targetUser;
|
||||
}
|
||||
|
||||
private async syncKeycloakGroups(
|
||||
private async syncOidcGroups(
|
||||
userId: string,
|
||||
groups: string[],
|
||||
): Promise<void> {
|
||||
const normalizedGroups = this.normalizeGroupPaths(groups);
|
||||
|
||||
await this.userKeycloakGroupsRepository.delete({ userId });
|
||||
await this.userOidcGroupsRepository.delete({ userId });
|
||||
|
||||
if (!normalizedGroups.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.userKeycloakGroupsRepository.save(
|
||||
await this.userOidcGroupsRepository.save(
|
||||
normalizedGroups.map((groupPath) =>
|
||||
this.userKeycloakGroupsRepository.create({
|
||||
this.userOidcGroupsRepository.create({
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
groupPath,
|
||||
@@ -418,7 +459,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
private async getUserGroupPaths(userId: string): Promise<string[]> {
|
||||
const groups = await this.userKeycloakGroupsRepository.find({
|
||||
const groups = await this.userOidcGroupsRepository.find({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
@@ -427,6 +468,36 @@ export class AuthService {
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
private async resolveUserAuthorization(
|
||||
userId: string,
|
||||
): Promise<UserAuthorization> {
|
||||
const groupPaths = await this.getUserGroupPaths(userId);
|
||||
const mappedRoles = groupPaths.length
|
||||
? await this.oidcGroupRoleMappingsRepository.find({
|
||||
where: { groupPath: In(groupPaths), enabled: true },
|
||||
})
|
||||
: [];
|
||||
const roles = this.sortUnique([
|
||||
DEFAULT_APP_ROLE,
|
||||
...mappedRoles.map((mapping) => mapping.role),
|
||||
]);
|
||||
const rolePermissions = await this.appRolePermissionsRepository.find({
|
||||
where: { role: In(roles) },
|
||||
});
|
||||
const permissions = this.sortUnique(
|
||||
rolePermissions.map((rolePermission) => rolePermission.permission),
|
||||
);
|
||||
|
||||
return { roles, permissions };
|
||||
}
|
||||
|
||||
private sortUnique(values: string[]): string[] {
|
||||
return [...new Set(values)]
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
private secretMatches(secret: string, storedSecretHash: string): boolean {
|
||||
const [salt, storedHash] = storedSecretHash.split(':');
|
||||
|
||||
@@ -503,6 +574,19 @@ export class AuthService {
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
private async revokeRefreshToken(refreshToken?: string): Promise<void> {
|
||||
if (!refreshToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = this.verifyRefreshToken(refreshToken);
|
||||
await this.refreshTokensRepository.delete({ jti: payload.jti });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private hashToken(token: string): string {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const hash = scryptSync(token, salt, 64).toString('hex');
|
||||
@@ -513,11 +597,28 @@ export class AuthService {
|
||||
return this.secretMatches(token, tokenHash);
|
||||
}
|
||||
|
||||
private async toPublicUserWithGroups(user: UserEntity): Promise<PublicUser> {
|
||||
return this.toPublicUser(user, await this.getUserGroupPaths(user.id));
|
||||
private async toPublicUserWithAuthorization(
|
||||
user: UserEntity,
|
||||
): Promise<PublicUser> {
|
||||
const [groups, authorization] = await Promise.all([
|
||||
this.getUserGroupPaths(user.id),
|
||||
this.resolveUserAuthorization(user.id),
|
||||
]);
|
||||
|
||||
return this.toPublicUser(
|
||||
user,
|
||||
groups,
|
||||
authorization.roles,
|
||||
authorization.permissions,
|
||||
);
|
||||
}
|
||||
|
||||
private toPublicUser(user: UserEntity, groups: string[] = []): PublicUser {
|
||||
private toPublicUser(
|
||||
user: UserEntity,
|
||||
groups: string[] = [],
|
||||
roles: string[] = [],
|
||||
permissions: string[] = [],
|
||||
): PublicUser {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@@ -525,6 +626,8 @@ export class AuthService {
|
||||
onboardingCompleted: user.onboardingCompleted === true,
|
||||
taskDigestPreference: user.taskDigestPreference ?? 'both',
|
||||
groups,
|
||||
roles,
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ export interface AuthTokens {
|
||||
|
||||
export interface AuthTokenResponse extends AuthTokens {
|
||||
user: PublicUser;
|
||||
idToken?: string;
|
||||
}
|
||||
|
||||
export interface AuthLogoutResponse {
|
||||
logoutUrl: string;
|
||||
}
|
||||
|
||||
export interface JwtTokenPayload {
|
||||
@@ -17,6 +22,8 @@ export interface JwtTokenPayload {
|
||||
jti?: string;
|
||||
impersonatorSub?: string;
|
||||
impersonatorEmail?: string;
|
||||
roles?: string[];
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
@@ -30,6 +37,8 @@ export interface PublicUser {
|
||||
onboardingCompleted: boolean;
|
||||
taskDigestPreference: TaskDigestPreference;
|
||||
groups: string[];
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface PublicUserSearchResult {
|
||||
|
||||
195
listify-api/src/auth/authz-seed.service.ts
Normal file
195
listify-api/src/auth/authz-seed.service.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AppPermissionEntity } from './app-permission.entity';
|
||||
import { AppRolePermissionEntity } from './app-role-permission.entity';
|
||||
import { AppRoleEntity } from './app-role.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AuthzSeedService implements OnModuleInit {
|
||||
constructor(
|
||||
@InjectRepository(AppRoleEntity)
|
||||
private readonly appRolesRepository: Repository<AppRoleEntity>,
|
||||
@InjectRepository(AppPermissionEntity)
|
||||
private readonly appPermissionsRepository: Repository<AppPermissionEntity>,
|
||||
@InjectRepository(AppRolePermissionEntity)
|
||||
private readonly appRolePermissionsRepository: Repository<AppRolePermissionEntity>,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.appRolesRepository.save([
|
||||
this.appRolesRepository.create({
|
||||
role: 'app_user',
|
||||
label: 'User',
|
||||
description: 'Basiszugriff fuer angemeldete Benutzer',
|
||||
}),
|
||||
this.appRolesRepository.create({
|
||||
role: 'app_admin',
|
||||
label: 'Admin',
|
||||
description: 'Voller App-Zugriff inklusive Diagnosefunktionen',
|
||||
}),
|
||||
]);
|
||||
|
||||
await this.appPermissionsRepository.save([
|
||||
permission(
|
||||
this.appPermissionsRepository,
|
||||
'dashboard.view',
|
||||
'Dashboard ansehen',
|
||||
'Dashboard lesen',
|
||||
),
|
||||
permission(
|
||||
this.appPermissionsRepository,
|
||||
'lists.manage_own',
|
||||
'Eigene Listen verwalten',
|
||||
'Eigene und geteilte Listen nutzen',
|
||||
),
|
||||
permission(
|
||||
this.appPermissionsRepository,
|
||||
'templates.manage_own',
|
||||
'Eigene Templates verwalten',
|
||||
'Eigene und geteilte Templates nutzen',
|
||||
),
|
||||
permission(
|
||||
this.appPermissionsRepository,
|
||||
'tasks.manage_own',
|
||||
'Eigene Tasks verwalten',
|
||||
'Eigene Tasks nutzen',
|
||||
),
|
||||
permission(
|
||||
this.appPermissionsRepository,
|
||||
'assistant.chat',
|
||||
'Assistant Chat nutzen',
|
||||
'Assistant Chat verwenden',
|
||||
),
|
||||
permission(
|
||||
this.appPermissionsRepository,
|
||||
'assistant.logs.view',
|
||||
'Assistant Logs ansehen',
|
||||
'Assistant Chat Logs ansehen',
|
||||
),
|
||||
permission(
|
||||
this.appPermissionsRepository,
|
||||
'account.manage_self',
|
||||
'Eigenes Konto verwalten',
|
||||
'Eigene Konto-Einstellungen verwalten',
|
||||
),
|
||||
permission(
|
||||
this.appPermissionsRepository,
|
||||
'users.search',
|
||||
'Benutzer suchen',
|
||||
'Benutzer fuer Freigaben suchen',
|
||||
),
|
||||
]);
|
||||
|
||||
await this.appRolePermissionsRepository.save([
|
||||
rolePermission(
|
||||
this.appRolePermissionsRepository,
|
||||
'rp-user-dashboard',
|
||||
'app_user',
|
||||
'dashboard.view',
|
||||
),
|
||||
rolePermission(
|
||||
this.appRolePermissionsRepository,
|
||||
'rp-user-lists',
|
||||
'app_user',
|
||||
'lists.manage_own',
|
||||
),
|
||||
rolePermission(
|
||||
this.appRolePermissionsRepository,
|
||||
'rp-user-templates',
|
||||
'app_user',
|
||||
'templates.manage_own',
|
||||
),
|
||||
rolePermission(
|
||||
this.appRolePermissionsRepository,
|
||||
'rp-user-tasks',
|
||||
'app_user',
|
||||
'tasks.manage_own',
|
||||
),
|
||||
rolePermission(
|
||||
this.appRolePermissionsRepository,
|
||||
'rp-user-assistant-chat',
|
||||
'app_user',
|
||||
'assistant.chat',
|
||||
),
|
||||
rolePermission(
|
||||
this.appRolePermissionsRepository,
|
||||
'rp-user-account',
|
||||
'app_user',
|
||||
'account.manage_self',
|
||||
),
|
||||
rolePermission(
|
||||
this.appRolePermissionsRepository,
|
||||
'rp-user-search',
|
||||
'app_user',
|
||||
'users.search',
|
||||
),
|
||||
rolePermission(
|
||||
this.appRolePermissionsRepository,
|
||||
'rp-admin-dashboard',
|
||||
'app_admin',
|
||||
'dashboard.view',
|
||||
),
|
||||
rolePermission(
|
||||
this.appRolePermissionsRepository,
|
||||
'rp-admin-lists',
|
||||
'app_admin',
|
||||
'lists.manage_own',
|
||||
),
|
||||
rolePermission(
|
||||
this.appRolePermissionsRepository,
|
||||
'rp-admin-templates',
|
||||
'app_admin',
|
||||
'templates.manage_own',
|
||||
),
|
||||
rolePermission(
|
||||
this.appRolePermissionsRepository,
|
||||
'rp-admin-tasks',
|
||||
'app_admin',
|
||||
'tasks.manage_own',
|
||||
),
|
||||
rolePermission(
|
||||
this.appRolePermissionsRepository,
|
||||
'rp-admin-assistant-chat',
|
||||
'app_admin',
|
||||
'assistant.chat',
|
||||
),
|
||||
rolePermission(
|
||||
this.appRolePermissionsRepository,
|
||||
'rp-admin-assistant-logs',
|
||||
'app_admin',
|
||||
'assistant.logs.view',
|
||||
),
|
||||
rolePermission(
|
||||
this.appRolePermissionsRepository,
|
||||
'rp-admin-account',
|
||||
'app_admin',
|
||||
'account.manage_self',
|
||||
),
|
||||
rolePermission(
|
||||
this.appRolePermissionsRepository,
|
||||
'rp-admin-search',
|
||||
'app_admin',
|
||||
'users.search',
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function permission(
|
||||
repository: Repository<AppPermissionEntity>,
|
||||
value: string,
|
||||
label: string,
|
||||
description: string,
|
||||
): AppPermissionEntity {
|
||||
return repository.create({ permission: value, label, description });
|
||||
}
|
||||
|
||||
function rolePermission(
|
||||
repository: Repository<AppRolePermissionEntity>,
|
||||
id: string,
|
||||
role: string,
|
||||
permissionValue: string,
|
||||
): AppRolePermissionEntity {
|
||||
return repository.create({ id, role, permission: permissionValue });
|
||||
}
|
||||
18
listify-api/src/auth/authz.constants.ts
Normal file
18
listify-api/src/auth/authz.constants.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const DEFAULT_APP_ROLE = 'app_user';
|
||||
|
||||
export const APP_ROLES = ['app_user', 'app_admin'] as const;
|
||||
|
||||
export type AppRole = (typeof APP_ROLES)[number] | string;
|
||||
|
||||
export const APP_PERMISSIONS = [
|
||||
'dashboard.view',
|
||||
'lists.manage_own',
|
||||
'templates.manage_own',
|
||||
'tasks.manage_own',
|
||||
'assistant.chat',
|
||||
'assistant.logs.view',
|
||||
'account.manage_self',
|
||||
'users.search',
|
||||
] as const;
|
||||
|
||||
export type AppPermission = (typeof APP_PERMISSIONS)[number] | string;
|
||||
@@ -3,12 +3,14 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { AppRolePermissionEntity } from './app-role-permission.entity';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthenticatedRequest } from './auth.types';
|
||||
import { OidcGroupRoleMappingEntity } from './oidc-group-role-mapping.entity';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { OidcService } from './oidc.service';
|
||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||
import { UserKeycloakGroupEntity } from './user-keycloak-group.entity';
|
||||
import { UserOidcGroupEntity } from './user-oidc-group.entity';
|
||||
import { UserImpersonationEntity } from './user-impersonation.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
import { InMemoryRepository } from '../testing/in-memory-repository';
|
||||
@@ -27,15 +29,21 @@ describe('JwtAuthGuard', () => {
|
||||
{
|
||||
provide: OidcService,
|
||||
useValue: {
|
||||
createAuthorizationUrl: jest.fn(
|
||||
async () => 'https://sso.example.test/authorize',
|
||||
createAuthorizationUrl: jest.fn(() =>
|
||||
Promise.resolve('https://sso.example.test/authorize'),
|
||||
),
|
||||
exchangeCallback: jest.fn(async () => ({
|
||||
exchangeCallback: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
subject: 'oidc-user-1',
|
||||
email: 'user@example.com',
|
||||
name: 'Test User',
|
||||
groups: [],
|
||||
})),
|
||||
idToken: 'id-token',
|
||||
}),
|
||||
),
|
||||
createLogoutUrl: jest.fn(() =>
|
||||
Promise.resolve('https://sso.example.test/logout'),
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -47,13 +55,21 @@ describe('JwtAuthGuard', () => {
|
||||
useValue: new InMemoryRepository<RefreshTokenEntity>(),
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserKeycloakGroupEntity),
|
||||
useValue: new InMemoryRepository<UserKeycloakGroupEntity>(),
|
||||
provide: getRepositoryToken(UserOidcGroupEntity),
|
||||
useValue: new InMemoryRepository<UserOidcGroupEntity>(),
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserImpersonationEntity),
|
||||
useValue: new InMemoryRepository<UserImpersonationEntity>(),
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AppRolePermissionEntity),
|
||||
useValue: new InMemoryRepository<AppRolePermissionEntity>(),
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(OidcGroupRoleMappingEntity),
|
||||
useValue: new InMemoryRepository<OidcGroupRoleMappingEntity>(),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
await module.init();
|
||||
|
||||
46
listify-api/src/auth/oidc-group-role-mapping.entity.ts
Normal file
46
listify-api/src/auth/oidc-group-role-mapping.entity.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('oidc_group_role_mappings')
|
||||
@Index('IDX_oidc_group_role_mappings_group_role', ['groupPath', 'role'], {
|
||||
unique: true,
|
||||
})
|
||||
export class OidcGroupRoleMappingEntity {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
groupPath!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'varchar', length: 80 })
|
||||
role!: string;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
reason?: string | null;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'datetime',
|
||||
precision: 3,
|
||||
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||
})
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
type: 'datetime',
|
||||
precision: 3,
|
||||
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||
onUpdate: 'CURRENT_TIMESTAMP(3)',
|
||||
})
|
||||
updatedAt!: Date;
|
||||
}
|
||||
250
listify-api/src/auth/oidc.service.spec.ts
Normal file
250
listify-api/src/auth/oidc.service.spec.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { OidcService } from './oidc.service';
|
||||
|
||||
type TokenIntrospectionMock = Record<string, unknown> & {
|
||||
active?: boolean;
|
||||
iss?: string;
|
||||
aud?: string | string[];
|
||||
sub?: string;
|
||||
};
|
||||
|
||||
describe('OidcService', () => {
|
||||
const issuer = 'https://id.example.test';
|
||||
const clientId = 'listify';
|
||||
const redirectUri = 'http://localhost:4200/auth/sso/callback';
|
||||
const postLogoutRedirectUri = 'http://localhost:4200/login';
|
||||
const idToken = 'id-token';
|
||||
const accessToken = 'access-token';
|
||||
const idTokenHint = 'id-token-hint';
|
||||
let service: OidcService;
|
||||
let fetchMock: jest.Mock;
|
||||
let jwtVerify: jest.Mock;
|
||||
let jwks: object;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.OIDC_ISSUER = issuer;
|
||||
process.env.OIDC_CLIENT_ID = clientId;
|
||||
process.env.OIDC_CLIENT_SECRET = 'client-secret';
|
||||
process.env.OIDC_SCOPES = 'openid profile email groups';
|
||||
process.env.OIDC_REDIRECT_URI = redirectUri;
|
||||
process.env.OIDC_POST_LOGOUT_REDIRECT_URI = postLogoutRedirectUri;
|
||||
process.env.OIDC_ACCESS_TOKEN_AUDIENCE = clientId;
|
||||
|
||||
service = new OidcService();
|
||||
fetchMock = jest.fn();
|
||||
jwtVerify = jest.fn();
|
||||
jwks = {};
|
||||
global.fetch = fetchMock;
|
||||
jest
|
||||
.spyOn(
|
||||
service as unknown as { getJose: () => Promise<unknown> },
|
||||
'getJose',
|
||||
)
|
||||
.mockResolvedValue({ jwtVerify });
|
||||
jest
|
||||
.spyOn(
|
||||
service as unknown as { getJwks: () => Promise<object> },
|
||||
'getJwks',
|
||||
)
|
||||
.mockResolvedValue(jwks);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
delete process.env.OIDC_ISSUER;
|
||||
delete process.env.OIDC_CLIENT_ID;
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
delete process.env.OIDC_SCOPES;
|
||||
delete process.env.OIDC_REDIRECT_URI;
|
||||
delete process.env.OIDC_POST_LOGOUT_REDIRECT_URI;
|
||||
delete process.env.OIDC_ACCESS_TOKEN_AUDIENCE;
|
||||
});
|
||||
|
||||
it('creates a discovery based authorization URL with PKCE', async () => {
|
||||
mockDiscovery();
|
||||
|
||||
const authorizationUrl = new URL(await service.createAuthorizationUrl());
|
||||
|
||||
expect(authorizationUrl.origin + authorizationUrl.pathname).toBe(
|
||||
`${issuer}/oidc/auth`,
|
||||
);
|
||||
expect(authorizationUrl.searchParams.get('response_type')).toBe('code');
|
||||
expect(authorizationUrl.searchParams.get('client_id')).toBe(clientId);
|
||||
expect(authorizationUrl.searchParams.get('redirect_uri')).toBe(redirectUri);
|
||||
expect(authorizationUrl.searchParams.get('scope')).toBe(
|
||||
'openid profile email groups',
|
||||
);
|
||||
expect(authorizationUrl.searchParams.get('code_challenge')).toBeTruthy();
|
||||
expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe(
|
||||
'S256',
|
||||
);
|
||||
});
|
||||
|
||||
it('validates the ID token through JWKS, introspects the opaque access token, and maps groups', async () => {
|
||||
mockDiscovery();
|
||||
const authorizationUrl = new URL(await service.createAuthorizationUrl());
|
||||
const state = authorizationUrl.searchParams.get('state') ?? '';
|
||||
const nonce = authorizationUrl.searchParams.get('nonce') ?? '';
|
||||
mockTokenResponse();
|
||||
jwtVerify.mockResolvedValueOnce({
|
||||
payload: {
|
||||
sub: 'user-1',
|
||||
nonce,
|
||||
email: 'user@example.test',
|
||||
preferred_username: 'u.test',
|
||||
given_name: 'Test',
|
||||
family_name: 'User',
|
||||
name: 'Test User',
|
||||
},
|
||||
});
|
||||
|
||||
const profile = await service.exchangeCallback('code', state);
|
||||
|
||||
expect(jwtVerify).toHaveBeenCalledTimes(1);
|
||||
expect(jwtVerify).toHaveBeenCalledWith(idToken, jwks, {
|
||||
issuer,
|
||||
audience: clientId,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${issuer}/oidc/token/introspection`,
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
}),
|
||||
);
|
||||
expect(profile).toEqual({
|
||||
subject: 'user-1',
|
||||
email: 'user@example.test',
|
||||
name: 'Test User',
|
||||
preferredUsername: 'u.test',
|
||||
givenName: 'Test',
|
||||
familyName: 'User',
|
||||
groups: ['/teams/admins', '/teams/engineering'],
|
||||
idToken,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects opaque access tokens with a wrong audience from introspection', async () => {
|
||||
mockDiscovery();
|
||||
const authorizationUrl = new URL(await service.createAuthorizationUrl());
|
||||
const state = authorizationUrl.searchParams.get('state') ?? '';
|
||||
const nonce = authorizationUrl.searchParams.get('nonce') ?? '';
|
||||
mockTokenResponse({ active: true, iss: issuer, aud: 'other-audience' });
|
||||
jwtVerify.mockResolvedValueOnce({
|
||||
payload: {
|
||||
sub: 'user-1',
|
||||
nonce,
|
||||
email: 'user@example.test',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.exchangeCallback('code', state)).rejects.toThrow(
|
||||
'OIDC access token audience is invalid.',
|
||||
);
|
||||
});
|
||||
|
||||
it('creates provider logout URLs with id token hint', async () => {
|
||||
mockDiscovery();
|
||||
|
||||
const logoutUrl = new URL(await service.createLogoutUrl(idTokenHint));
|
||||
|
||||
expect(logoutUrl.origin + logoutUrl.pathname).toBe(
|
||||
`${issuer}/oidc/session/end`,
|
||||
);
|
||||
expect(logoutUrl.searchParams.get('id_token_hint')).toBe(idTokenHint);
|
||||
expect(logoutUrl.searchParams.get('post_logout_redirect_uri')).toBe(
|
||||
postLogoutRedirectUri,
|
||||
);
|
||||
});
|
||||
|
||||
function mockDiscovery(): void {
|
||||
fetchMock.mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = requestUrl(input);
|
||||
|
||||
if (url === `${issuer}/.well-known/openid-configuration`) {
|
||||
return Promise.resolve(jsonResponse(discoveryPayload()));
|
||||
}
|
||||
|
||||
if (url === `${issuer}/oidc/me`) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
groups: ['/teams/engineering', '/teams/admins'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
});
|
||||
}
|
||||
|
||||
function mockTokenResponse(
|
||||
introspectionPayload: TokenIntrospectionMock = {
|
||||
active: true,
|
||||
iss: issuer,
|
||||
aud: clientId,
|
||||
sub: 'user-1',
|
||||
},
|
||||
): void {
|
||||
fetchMock.mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = requestUrl(input);
|
||||
|
||||
if (url === `${issuer}/.well-known/openid-configuration`) {
|
||||
return Promise.resolve(jsonResponse(discoveryPayload()));
|
||||
}
|
||||
|
||||
if (url === `${issuer}/oidc/token`) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
id_token: idToken,
|
||||
access_token: accessToken,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === `${issuer}/oidc/token/introspection`) {
|
||||
return Promise.resolve(jsonResponse(introspectionPayload));
|
||||
}
|
||||
|
||||
if (url === `${issuer}/oidc/me`) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
sub: 'user-1',
|
||||
groups: ['/teams/engineering', '/teams/admins'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
});
|
||||
}
|
||||
|
||||
function discoveryPayload(): Record<string, string> {
|
||||
return {
|
||||
issuer,
|
||||
authorization_endpoint: `${issuer}/oidc/auth`,
|
||||
token_endpoint: `${issuer}/oidc/token`,
|
||||
introspection_endpoint: `${issuer}/oidc/token/introspection`,
|
||||
userinfo_endpoint: `${issuer}/oidc/me`,
|
||||
jwks_uri: `${issuer}/oidc/jwks`,
|
||||
end_session_endpoint: `${issuer}/oidc/session/end`,
|
||||
};
|
||||
}
|
||||
|
||||
function requestUrl(input: RequestInfo | URL): string {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (input instanceof URL) {
|
||||
return input.toString();
|
||||
}
|
||||
|
||||
return input.url;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import type { JWTPayload } from 'jose';
|
||||
|
||||
type JoseModule = typeof import('jose');
|
||||
type RemoteJwkSet = ReturnType<JoseModule['createRemoteJWKSet']>;
|
||||
@@ -12,15 +13,21 @@ export interface OidcProfile {
|
||||
subject: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
preferredUsername?: string;
|
||||
givenName?: string;
|
||||
familyName?: string;
|
||||
groups: string[];
|
||||
idToken: string;
|
||||
}
|
||||
|
||||
interface OidcDiscovery {
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
introspection_endpoint?: string;
|
||||
userinfo_endpoint?: string;
|
||||
jwks_uri: string;
|
||||
issuer: string;
|
||||
end_session_endpoint?: string;
|
||||
}
|
||||
|
||||
interface PendingOidcState {
|
||||
@@ -36,6 +43,15 @@ interface TokenResponse {
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
interface TokenIntrospectionResponse {
|
||||
active?: boolean;
|
||||
sub?: string;
|
||||
iss?: string;
|
||||
aud?: string | string[];
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OidcService {
|
||||
private readonly pendingStates = new Map<string, PendingOidcState>();
|
||||
@@ -45,7 +61,7 @@ export class OidcService {
|
||||
|
||||
async createAuthorizationUrl(): Promise<string> {
|
||||
const config = this.getConfig();
|
||||
const discovery = await this.getDiscovery(config.discoveryUrl);
|
||||
const discovery = await this.getDiscovery(config);
|
||||
const state = this.createOpaqueToken();
|
||||
const nonce = this.createOpaqueToken();
|
||||
const codeVerifier = this.createOpaqueToken();
|
||||
@@ -61,8 +77,8 @@ export class OidcService {
|
||||
|
||||
authorizationUrl.searchParams.set('response_type', 'code');
|
||||
authorizationUrl.searchParams.set('client_id', config.clientId);
|
||||
authorizationUrl.searchParams.set('redirect_uri', config.callbackUrl);
|
||||
authorizationUrl.searchParams.set('scope', config.scope);
|
||||
authorizationUrl.searchParams.set('redirect_uri', config.redirectUri);
|
||||
authorizationUrl.searchParams.set('scope', config.scopes);
|
||||
authorizationUrl.searchParams.set('state', state);
|
||||
authorizationUrl.searchParams.set('nonce', nonce);
|
||||
authorizationUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
@@ -84,7 +100,7 @@ export class OidcService {
|
||||
}
|
||||
|
||||
const config = this.getConfig();
|
||||
const discovery = await this.getDiscovery(config.discoveryUrl);
|
||||
const discovery = await this.getDiscovery(config);
|
||||
const tokenResponse = await this.requestTokens(
|
||||
discovery,
|
||||
config,
|
||||
@@ -92,12 +108,11 @@ export class OidcService {
|
||||
pendingState.codeVerifier,
|
||||
);
|
||||
|
||||
console.log(tokenResponse)
|
||||
if (!tokenResponse.id_token) {
|
||||
if (!tokenResponse.id_token || !tokenResponse.access_token) {
|
||||
throw new ServiceUnavailableException(
|
||||
tokenResponse.error_description ??
|
||||
tokenResponse.error ??
|
||||
'OIDC token response did not include an ID token.',
|
||||
'OIDC token response did not include the required tokens.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,41 +120,85 @@ export class OidcService {
|
||||
this.getJose(),
|
||||
this.getJwks(discovery.jwks_uri),
|
||||
]);
|
||||
const { payload } = await jwtVerify(tokenResponse.id_token, jwks, {
|
||||
const { payload: idTokenPayload } = await jwtVerify(
|
||||
tokenResponse.id_token,
|
||||
jwks,
|
||||
{
|
||||
issuer: discovery.issuer,
|
||||
audience: config.clientId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (payload.nonce !== pendingState.nonce) {
|
||||
if (idTokenPayload.nonce !== pendingState.nonce) {
|
||||
throw new BadRequestException('OIDC nonce is invalid.');
|
||||
}
|
||||
|
||||
if (!payload.sub || typeof payload.sub !== 'string') {
|
||||
if (!idTokenPayload.sub || typeof idTokenPayload.sub !== 'string') {
|
||||
throw new BadRequestException('OIDC subject is missing.');
|
||||
}
|
||||
|
||||
const email = typeof payload.email === 'string' ? payload.email : undefined;
|
||||
await this.introspectAccessToken(
|
||||
discovery,
|
||||
config,
|
||||
tokenResponse.access_token,
|
||||
idTokenPayload.sub,
|
||||
);
|
||||
|
||||
const userInfo = await this.requestUserInfo(
|
||||
discovery.userinfo_endpoint,
|
||||
tokenResponse.access_token,
|
||||
);
|
||||
this.validateUserInfoSubject(userInfo, idTokenPayload.sub);
|
||||
|
||||
const mergedClaims = { ...idTokenPayload, ...userInfo };
|
||||
const email = this.stringClaim(mergedClaims, 'email');
|
||||
|
||||
if (!email) {
|
||||
throw new BadRequestException('OIDC email claim is missing.');
|
||||
}
|
||||
|
||||
const idTokenGroups = this.extractGroups(payload, config.groupsClaim);
|
||||
const groups = this.extractGroups(mergedClaims, config.groupsClaim);
|
||||
const givenName = this.stringClaim(mergedClaims, 'given_name');
|
||||
const familyName = this.stringClaim(mergedClaims, 'family_name');
|
||||
const preferredUsername = this.stringClaim(
|
||||
mergedClaims,
|
||||
'preferred_username',
|
||||
);
|
||||
|
||||
return {
|
||||
subject: payload.sub,
|
||||
subject: idTokenPayload.sub,
|
||||
email,
|
||||
name: typeof payload.name === 'string' ? payload.name : undefined,
|
||||
groups: idTokenGroups.length
|
||||
? idTokenGroups
|
||||
: await this.requestUserInfoGroups(
|
||||
discovery.userinfo_endpoint,
|
||||
tokenResponse.access_token,
|
||||
config.groupsClaim,
|
||||
),
|
||||
name: this.displayName(mergedClaims, preferredUsername),
|
||||
preferredUsername,
|
||||
givenName,
|
||||
familyName,
|
||||
groups,
|
||||
idToken: tokenResponse.id_token,
|
||||
};
|
||||
}
|
||||
|
||||
async createLogoutUrl(idTokenHint?: string): Promise<string> {
|
||||
const config = this.getConfig();
|
||||
const discovery = await this.getDiscovery(config);
|
||||
const logoutUrl = new URL(
|
||||
discovery.end_session_endpoint ??
|
||||
`${config.issuer.replace(/\/$/, '')}/oidc/session/end`,
|
||||
);
|
||||
|
||||
if (idTokenHint) {
|
||||
logoutUrl.searchParams.set('id_token_hint', idTokenHint);
|
||||
}
|
||||
|
||||
if (config.postLogoutRedirectUri) {
|
||||
logoutUrl.searchParams.set(
|
||||
'post_logout_redirect_uri',
|
||||
config.postLogoutRedirectUri,
|
||||
);
|
||||
}
|
||||
|
||||
return logoutUrl.toString();
|
||||
}
|
||||
|
||||
private async requestTokens(
|
||||
discovery: OidcDiscovery,
|
||||
config: ReturnType<OidcService['getConfig']>,
|
||||
@@ -149,14 +208,12 @@ export class OidcService {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: config.callbackUrl,
|
||||
redirect_uri: config.redirectUri,
|
||||
client_id: config.clientId,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
if (config.clientSecret) {
|
||||
body.set('client_secret', config.clientSecret);
|
||||
}
|
||||
this.addClientAuthentication(body, config);
|
||||
|
||||
const response = await fetch(discovery.token_endpoint, {
|
||||
method: 'POST',
|
||||
@@ -176,18 +233,81 @@ export class OidcService {
|
||||
return payload;
|
||||
}
|
||||
|
||||
private async getDiscovery(discoveryUrl: string): Promise<OidcDiscovery> {
|
||||
private async introspectAccessToken(
|
||||
discovery: OidcDiscovery,
|
||||
config: ReturnType<OidcService['getConfig']>,
|
||||
accessToken: string,
|
||||
expectedSubject: string,
|
||||
): Promise<void> {
|
||||
const body = new URLSearchParams({
|
||||
token: accessToken,
|
||||
token_type_hint: 'access_token',
|
||||
});
|
||||
this.addClientAuthentication(body, config);
|
||||
|
||||
const response = await fetch(
|
||||
discovery.introspection_endpoint ??
|
||||
`${config.issuer}/oidc/token/introspection`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
},
|
||||
);
|
||||
const payload = (await response
|
||||
.json()
|
||||
.catch(() => ({}))) as TokenIntrospectionResponse;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ServiceUnavailableException(
|
||||
payload.error_description ??
|
||||
payload.error ??
|
||||
'OIDC token introspection failed.',
|
||||
);
|
||||
}
|
||||
|
||||
if (payload.active !== true) {
|
||||
throw new BadRequestException('OIDC access token is inactive.');
|
||||
}
|
||||
|
||||
if (payload.iss && this.normalizeIssuer(payload.iss) !== config.issuer) {
|
||||
throw new BadRequestException('OIDC access token issuer is invalid.');
|
||||
}
|
||||
|
||||
if (payload.sub && payload.sub !== expectedSubject) {
|
||||
throw new BadRequestException('OIDC access token subject is invalid.');
|
||||
}
|
||||
|
||||
if (
|
||||
payload.aud &&
|
||||
!this.audienceIncludes(payload.aud, config.accessTokenAudience)
|
||||
) {
|
||||
throw new BadRequestException('OIDC access token audience is invalid.');
|
||||
}
|
||||
}
|
||||
|
||||
private async getDiscovery(
|
||||
config: ReturnType<OidcService['getConfig']>,
|
||||
): Promise<OidcDiscovery> {
|
||||
if (this.discovery) {
|
||||
return this.discovery;
|
||||
}
|
||||
|
||||
const response = await fetch(discoveryUrl);
|
||||
const response = await fetch(config.discoveryUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ServiceUnavailableException('OIDC discovery failed.');
|
||||
}
|
||||
|
||||
this.discovery = (await response.json()) as OidcDiscovery;
|
||||
const discovery = (await response.json()) as OidcDiscovery;
|
||||
|
||||
if (this.normalizeIssuer(discovery.issuer) !== config.issuer) {
|
||||
throw new ServiceUnavailableException(
|
||||
'OIDC discovery issuer does not match OIDC_ISSUER.',
|
||||
);
|
||||
}
|
||||
|
||||
this.discovery = discovery;
|
||||
return this.discovery;
|
||||
}
|
||||
|
||||
@@ -204,37 +324,38 @@ export class OidcService {
|
||||
}
|
||||
|
||||
private getConfig() {
|
||||
const issuerUrl = process.env.OIDC_ISSUER_URL;
|
||||
const explicitDiscoveryUrl = process.env.OIDC_DISCOVERY_URL;
|
||||
const issuer = this.normalizeIssuer(
|
||||
process.env.OIDC_ISSUER ?? process.env.OIDC_ISSUER_URL,
|
||||
);
|
||||
const clientId = process.env.OIDC_CLIENT_ID;
|
||||
const callbackUrl = process.env.OIDC_CALLBACK_URL;
|
||||
const redirectUri =
|
||||
process.env.OIDC_REDIRECT_URI ?? process.env.OIDC_CALLBACK_URL;
|
||||
|
||||
if (!issuerUrl || !clientId || !callbackUrl) {
|
||||
if (!issuer || !clientId || !redirectUri) {
|
||||
throw new ServiceUnavailableException(
|
||||
'OIDC configuration is incomplete.',
|
||||
'OIDC configuration is incomplete. Required: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_REDIRECT_URI.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
issuerUrl,
|
||||
discoveryUrl:
|
||||
explicitDiscoveryUrl ??
|
||||
`${issuerUrl.replace(/\/$/, '')}/.well-known/openid-configuration`,
|
||||
issuer,
|
||||
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
|
||||
clientId,
|
||||
callbackUrl,
|
||||
redirectUri,
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||
scope: process.env.OIDC_SCOPE ?? 'openid email profile',
|
||||
scopes: process.env.OIDC_SCOPES ?? 'openid profile email groups',
|
||||
postLogoutRedirectUri: process.env.OIDC_POST_LOGOUT_REDIRECT_URI,
|
||||
accessTokenAudience: process.env.OIDC_ACCESS_TOKEN_AUDIENCE ?? clientId,
|
||||
groupsClaim: process.env.OIDC_GROUPS_CLAIM ?? 'groups',
|
||||
};
|
||||
}
|
||||
|
||||
private async requestUserInfoGroups(
|
||||
private async requestUserInfo(
|
||||
userInfoEndpoint: string | undefined,
|
||||
accessToken: string | undefined,
|
||||
groupsClaim: string,
|
||||
): Promise<string[]> {
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!userInfoEndpoint || !accessToken) {
|
||||
return [];
|
||||
return {};
|
||||
}
|
||||
|
||||
const response = await fetch(userInfoEndpoint, {
|
||||
@@ -242,15 +363,41 @@ export class OidcService {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
return {};
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return this.extractGroups(payload, groupsClaim);
|
||||
private addClientAuthentication(
|
||||
body: URLSearchParams,
|
||||
config: ReturnType<OidcService['getConfig']>,
|
||||
): void {
|
||||
body.set('client_id', config.clientId);
|
||||
|
||||
if (config.clientSecret) {
|
||||
body.set('client_secret', config.clientSecret);
|
||||
}
|
||||
}
|
||||
|
||||
private validateUserInfoSubject(
|
||||
userInfo: Record<string, unknown>,
|
||||
expectedSubject: string,
|
||||
): void {
|
||||
const subject = userInfo.sub;
|
||||
|
||||
if (typeof subject === 'string' && subject !== expectedSubject) {
|
||||
throw new BadRequestException('OIDC UserInfo subject is invalid.');
|
||||
}
|
||||
}
|
||||
|
||||
private audienceIncludes(
|
||||
audience: string | string[],
|
||||
expectedAudience: string,
|
||||
): boolean {
|
||||
return Array.isArray(audience)
|
||||
? audience.includes(expectedAudience)
|
||||
: audience === expectedAudience;
|
||||
}
|
||||
|
||||
private extractGroups(
|
||||
@@ -270,6 +417,31 @@ export class OidcService {
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
private displayName(
|
||||
payload: JWTPayload | Record<string, unknown>,
|
||||
preferredUsername?: string,
|
||||
): string | undefined {
|
||||
const explicitName = this.stringClaim(payload, 'name');
|
||||
const givenName = this.stringClaim(payload, 'given_name');
|
||||
const familyName = this.stringClaim(payload, 'family_name');
|
||||
const familyNameDisplay = [givenName, familyName].filter(Boolean).join(' ');
|
||||
|
||||
return explicitName ?? (familyNameDisplay || preferredUsername);
|
||||
}
|
||||
|
||||
private stringClaim(
|
||||
payload: JWTPayload | Record<string, unknown>,
|
||||
claim: string,
|
||||
): string | undefined {
|
||||
const value = payload[claim];
|
||||
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
private normalizeIssuer(issuer?: string): string {
|
||||
return issuer?.trim().replace(/\/$/, '') ?? '';
|
||||
}
|
||||
|
||||
private createOpaqueToken(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
67
listify-api/src/auth/permissions.guard.spec.ts
Normal file
67
listify-api/src/auth/permissions.guard.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PermissionsGuard } from './permissions.guard';
|
||||
import type { AuthenticatedRequest } from './auth.types';
|
||||
|
||||
describe('PermissionsGuard', () => {
|
||||
it('allows routes without required permissions', () => {
|
||||
const reflector = createReflector();
|
||||
const guard = new PermissionsGuard(reflector);
|
||||
|
||||
expect(guard.canActivate(createContext())).toBe(true);
|
||||
});
|
||||
|
||||
it('allows users with all required permissions', () => {
|
||||
const reflector = createReflector(['assistant.logs.view']);
|
||||
const guard = new PermissionsGuard(reflector);
|
||||
|
||||
expect(
|
||||
guard.canActivate(
|
||||
createContext({
|
||||
user: {
|
||||
sub: 'user-1',
|
||||
email: 'user@example.com',
|
||||
type: 'access',
|
||||
permissions: ['assistant.logs.view'],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects users missing a required permission', () => {
|
||||
const reflector = createReflector(['assistant.logs.view']);
|
||||
const guard = new PermissionsGuard(reflector);
|
||||
|
||||
expect(() =>
|
||||
guard.canActivate(
|
||||
createContext({
|
||||
user: {
|
||||
sub: 'user-1',
|
||||
email: 'user@example.com',
|
||||
type: 'access',
|
||||
permissions: ['assistant.chat'],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
function createReflector(requiredPermissions: string[] = []): Reflector {
|
||||
return {
|
||||
getAllAndOverride: jest.fn(() => requiredPermissions),
|
||||
} as unknown as Reflector;
|
||||
}
|
||||
|
||||
function createContext(
|
||||
request: Partial<AuthenticatedRequest> = {},
|
||||
): ExecutionContext {
|
||||
return {
|
||||
getHandler: () => undefined,
|
||||
getClass: () => undefined,
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
});
|
||||
39
listify-api/src/auth/permissions.guard.ts
Normal file
39
listify-api/src/auth/permissions.guard.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthenticatedRequest } from './auth.types';
|
||||
import { REQUIRED_PERMISSIONS_METADATA_KEY } from './require-permissions.decorator';
|
||||
import type { AppPermission } from './authz.constants';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredPermissions =
|
||||
this.reflector.getAllAndOverride<AppPermission[]>(
|
||||
REQUIRED_PERMISSIONS_METADATA_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
) ?? [];
|
||||
|
||||
if (!requiredPermissions.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const userPermissions = new Set(request.user?.permissions ?? []);
|
||||
const hasAllPermissions = requiredPermissions.every((permission) =>
|
||||
userPermissions.has(permission),
|
||||
);
|
||||
|
||||
if (!hasAllPermissions) {
|
||||
throw new ForbiddenException('Required app permission is missing.');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
7
listify-api/src/auth/require-permissions.decorator.ts
Normal file
7
listify-api/src/auth/require-permissions.decorator.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import type { AppPermission } from './authz.constants';
|
||||
|
||||
export const REQUIRED_PERMISSIONS_METADATA_KEY = 'requiredPermissions';
|
||||
|
||||
export const RequirePermissions = (...permissions: AppPermission[]) =>
|
||||
SetMetadata(REQUIRED_PERMISSIONS_METADATA_KEY, permissions);
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
} from 'typeorm';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
@Entity('user_keycloak_groups')
|
||||
@Index('IDX_user_keycloak_groups_user_group', ['userId', 'groupPath'], {
|
||||
@Entity('user_oidc_groups')
|
||||
@Index('IDX_user_oidc_groups_user_group', ['userId', 'groupPath'], {
|
||||
unique: true,
|
||||
})
|
||||
export class UserKeycloakGroupEntity {
|
||||
export class UserOidcGroupEntity {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id!: string;
|
||||
|
||||
@@ -3,9 +3,13 @@ import 'reflect-metadata';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AssistantChatLogEntity } from '../assistant/assistant-chat-log.entity';
|
||||
import { AuditLogEntity } from '../audit/audit-log.entity';
|
||||
import { AppPermissionEntity } from '../auth/app-permission.entity';
|
||||
import { AppRolePermissionEntity } from '../auth/app-role-permission.entity';
|
||||
import { AppRoleEntity } from '../auth/app-role.entity';
|
||||
import { UserEntity } from '../auth/user.entity';
|
||||
import { UserKeycloakGroupEntity } from '../auth/user-keycloak-group.entity';
|
||||
import { UserOidcGroupEntity } from '../auth/user-oidc-group.entity';
|
||||
import { UserImpersonationEntity } from '../auth/user-impersonation.entity';
|
||||
import { OidcGroupRoleMappingEntity } from '../auth/oidc-group-role-mapping.entity';
|
||||
import { RefreshTokenEntity } from '../auth/refresh-token.entity';
|
||||
import { DailyDashboardSnapshotEntity } from '../dashboard/daily-dashboard-snapshot.entity';
|
||||
import { WeeklyListSuggestionSnapshotEntity } from '../dashboard/weekly-list-suggestion-snapshot.entity';
|
||||
@@ -37,10 +41,14 @@ export default new DataSource({
|
||||
maxQueryExecutionTime: slowQueryThresholdFromEnv(process.env),
|
||||
entities: [
|
||||
AssistantChatLogEntity,
|
||||
AppRoleEntity,
|
||||
AppPermissionEntity,
|
||||
AppRolePermissionEntity,
|
||||
AuditLogEntity,
|
||||
DailyDashboardSnapshotEntity,
|
||||
OidcGroupRoleMappingEntity,
|
||||
UserEntity,
|
||||
UserKeycloakGroupEntity,
|
||||
UserOidcGroupEntity,
|
||||
UserImpersonationEntity,
|
||||
RefreshTokenEntity,
|
||||
ListTemplateEntity,
|
||||
|
||||
@@ -22,7 +22,11 @@ export function parseDatabaseLogging(value?: string | boolean): LoggerOptions {
|
||||
|
||||
const normalizedValue = value?.trim().toLowerCase();
|
||||
|
||||
if (!normalizedValue || normalizedValue === 'false' || normalizedValue === 'off') {
|
||||
if (
|
||||
!normalizedValue ||
|
||||
normalizedValue === 'false' ||
|
||||
normalizedValue === 'off'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { Logger as TypeOrmLogger, QueryRunner } from 'typeorm';
|
||||
import type { DatabaseLoggerOptions } from './database-logging.config';
|
||||
|
||||
const REDACTED = '[redacted]';
|
||||
const SENSITIVE_KEY_PATTERN = /password|token|secret|authorization|cookie|hash/i;
|
||||
const SENSITIVE_KEY_PATTERN =
|
||||
/password|token|secret|authorization|cookie|hash/i;
|
||||
|
||||
export class DatabaseLogger implements TypeOrmLogger {
|
||||
private readonly logger = new NestLogger('Database');
|
||||
@@ -15,7 +16,9 @@ export class DatabaseLogger implements TypeOrmLogger {
|
||||
parameters?: unknown[],
|
||||
queryRunner?: QueryRunner,
|
||||
): void {
|
||||
this.logger.debug(this.formatMessage('query', query, parameters, queryRunner));
|
||||
this.logger.debug(
|
||||
this.formatMessage('query', query, parameters, queryRunner),
|
||||
);
|
||||
}
|
||||
|
||||
logQueryError(
|
||||
@@ -48,11 +51,15 @@ export class DatabaseLogger implements TypeOrmLogger {
|
||||
}
|
||||
|
||||
logSchemaBuild(message: string, queryRunner?: QueryRunner): void {
|
||||
this.logger.log(this.formatMessage('schema', message, undefined, queryRunner));
|
||||
this.logger.log(
|
||||
this.formatMessage('schema', message, undefined, queryRunner),
|
||||
);
|
||||
}
|
||||
|
||||
logMigration(message: string, queryRunner?: QueryRunner): void {
|
||||
this.logger.log(this.formatMessage('migration', message, undefined, queryRunner));
|
||||
this.logger.log(
|
||||
this.formatMessage('migration', message, undefined, queryRunner),
|
||||
);
|
||||
}
|
||||
|
||||
log(
|
||||
@@ -60,7 +67,12 @@ export class DatabaseLogger implements TypeOrmLogger {
|
||||
message: string,
|
||||
queryRunner?: QueryRunner,
|
||||
): void {
|
||||
const formattedMessage = this.formatMessage(level, message, undefined, queryRunner);
|
||||
const formattedMessage = this.formatMessage(
|
||||
level,
|
||||
message,
|
||||
undefined,
|
||||
queryRunner,
|
||||
);
|
||||
|
||||
if (level === 'warn') {
|
||||
this.logger.warn(formattedMessage);
|
||||
@@ -108,12 +120,17 @@ export class DatabaseLogger implements TypeOrmLogger {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
|
||||
key,
|
||||
SENSITIVE_KEY_PATTERN.test(key) ? REDACTED : this.sanitizeValue(entry),
|
||||
SENSITIVE_KEY_PATTERN.test(key)
|
||||
? REDACTED
|
||||
: this.sanitizeValue(entry),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.length > this.options.maxParameterLength) {
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
value.length > this.options.maxParameterLength
|
||||
) {
|
||||
return `${value.slice(0, this.options.maxParameterLength)}...`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,62 +1,159 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class GeneratedMigration1780932637916 implements MigrationInterface {
|
||||
name = 'GeneratedMigration1780932637916'
|
||||
name = 'GeneratedMigration1780932637916';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`list_template_items\` DROP FOREIGN KEY \`FK_list_template_items_template_id\``);
|
||||
await queryRunner.query(`ALTER TABLE \`list_templates\` DROP FOREIGN KEY \`FK_list_templates_owner_id\``);
|
||||
await queryRunner.query(`ALTER TABLE \`user_list_items\` DROP FOREIGN KEY \`FK_user_list_items_list_id\``);
|
||||
await queryRunner.query(`ALTER TABLE \`user_lists\` DROP FOREIGN KEY \`FK_user_lists_owner_id\``);
|
||||
await queryRunner.query(`ALTER TABLE \`refresh_tokens\` DROP FOREIGN KEY \`FK_refresh_tokens_user_id\``);
|
||||
await queryRunner.query(`ALTER TABLE \`list_template_seeds\` DROP FOREIGN KEY \`FK_list_template_seeds_owner_id\``);
|
||||
await queryRunner.query(`DROP INDEX \`IDX_list_template_items_template_id\` ON \`list_template_items\``);
|
||||
await queryRunner.query(`DROP INDEX \`IDX_list_templates_owner_id\` ON \`list_templates\``);
|
||||
await queryRunner.query(`DROP INDEX \`IDX_user_list_items_list_id\` ON \`user_list_items\``);
|
||||
await queryRunner.query(`DROP INDEX \`IDX_user_lists_owner_id\` ON \`user_lists\``);
|
||||
await queryRunner.query(`DROP INDEX \`IDX_refresh_tokens_user_id\` ON \`refresh_tokens\``);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`list_template_items\` DROP FOREIGN KEY \`FK_list_template_items_template_id\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`list_templates\` DROP FOREIGN KEY \`FK_list_templates_owner_id\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`user_list_items\` DROP FOREIGN KEY \`FK_user_list_items_list_id\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`user_lists\` DROP FOREIGN KEY \`FK_user_lists_owner_id\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`refresh_tokens\` DROP FOREIGN KEY \`FK_refresh_tokens_user_id\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`list_template_seeds\` DROP FOREIGN KEY \`FK_list_template_seeds_owner_id\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_list_template_items_template_id\` ON \`list_template_items\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_list_templates_owner_id\` ON \`list_templates\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_user_list_items_list_id\` ON \`user_list_items\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_user_lists_owner_id\` ON \`user_lists\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_refresh_tokens_user_id\` ON \`refresh_tokens\``,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX \`IDX_users_email\` ON \`users\``);
|
||||
await queryRunner.query(`DROP INDEX \`IDX_users_verification_token\` ON \`users\``);
|
||||
await queryRunner.query(`ALTER TABLE \`users\` ADD UNIQUE INDEX \`IDX_97672ac88f789774dd47f7c8be\` (\`email\`)`);
|
||||
await queryRunner.query(`ALTER TABLE \`users\` ADD UNIQUE INDEX \`IDX_945333aaddfc5b9021b2ee94d5\` (\`verificationToken\`)`);
|
||||
await queryRunner.query(`CREATE INDEX \`IDX_82feb6202f10c7f7283d398014\` ON \`list_template_items\` (\`templateId\`)`);
|
||||
await queryRunner.query(`CREATE INDEX \`IDX_dca36cb201077233743d7355d2\` ON \`list_templates\` (\`ownerId\`)`);
|
||||
await queryRunner.query(`CREATE INDEX \`IDX_7dc61846f78234b1701413206d\` ON \`user_list_items\` (\`listId\`)`);
|
||||
await queryRunner.query(`CREATE INDEX \`IDX_20f32f84af2f8a3aa60d702326\` ON \`user_lists\` (\`ownerId\`)`);
|
||||
await queryRunner.query(`CREATE INDEX \`IDX_610102b60fea1455310ccd299d\` ON \`refresh_tokens\` (\`userId\`)`);
|
||||
await queryRunner.query(`ALTER TABLE \`list_template_items\` ADD CONSTRAINT \`FK_82feb6202f10c7f7283d3980144\` FOREIGN KEY (\`templateId\`) REFERENCES \`list_templates\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE \`list_templates\` ADD CONSTRAINT \`FK_dca36cb201077233743d7355d21\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE \`user_list_items\` ADD CONSTRAINT \`FK_7dc61846f78234b1701413206df\` FOREIGN KEY (\`listId\`) REFERENCES \`user_lists\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE \`user_lists\` ADD CONSTRAINT \`FK_20f32f84af2f8a3aa60d7023260\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE \`refresh_tokens\` ADD CONSTRAINT \`FK_610102b60fea1455310ccd299de\` FOREIGN KEY (\`userId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_users_verification_token\` ON \`users\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`users\` ADD UNIQUE INDEX \`IDX_97672ac88f789774dd47f7c8be\` (\`email\`)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`users\` ADD UNIQUE INDEX \`IDX_945333aaddfc5b9021b2ee94d5\` (\`verificationToken\`)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX \`IDX_82feb6202f10c7f7283d398014\` ON \`list_template_items\` (\`templateId\`)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX \`IDX_dca36cb201077233743d7355d2\` ON \`list_templates\` (\`ownerId\`)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX \`IDX_7dc61846f78234b1701413206d\` ON \`user_list_items\` (\`listId\`)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX \`IDX_20f32f84af2f8a3aa60d702326\` ON \`user_lists\` (\`ownerId\`)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX \`IDX_610102b60fea1455310ccd299d\` ON \`refresh_tokens\` (\`userId\`)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`list_template_items\` ADD CONSTRAINT \`FK_82feb6202f10c7f7283d3980144\` FOREIGN KEY (\`templateId\`) REFERENCES \`list_templates\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`list_templates\` ADD CONSTRAINT \`FK_dca36cb201077233743d7355d21\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`user_list_items\` ADD CONSTRAINT \`FK_7dc61846f78234b1701413206df\` FOREIGN KEY (\`listId\`) REFERENCES \`user_lists\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`user_lists\` ADD CONSTRAINT \`FK_20f32f84af2f8a3aa60d7023260\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`refresh_tokens\` ADD CONSTRAINT \`FK_610102b60fea1455310ccd299de\` FOREIGN KEY (\`userId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`refresh_tokens\` DROP FOREIGN KEY \`FK_610102b60fea1455310ccd299de\``);
|
||||
await queryRunner.query(`ALTER TABLE \`user_lists\` DROP FOREIGN KEY \`FK_20f32f84af2f8a3aa60d7023260\``);
|
||||
await queryRunner.query(`ALTER TABLE \`user_list_items\` DROP FOREIGN KEY \`FK_7dc61846f78234b1701413206df\``);
|
||||
await queryRunner.query(`ALTER TABLE \`list_templates\` DROP FOREIGN KEY \`FK_dca36cb201077233743d7355d21\``);
|
||||
await queryRunner.query(`ALTER TABLE \`list_template_items\` DROP FOREIGN KEY \`FK_82feb6202f10c7f7283d3980144\``);
|
||||
await queryRunner.query(`DROP INDEX \`IDX_610102b60fea1455310ccd299d\` ON \`refresh_tokens\``);
|
||||
await queryRunner.query(`DROP INDEX \`IDX_20f32f84af2f8a3aa60d702326\` ON \`user_lists\``);
|
||||
await queryRunner.query(`DROP INDEX \`IDX_7dc61846f78234b1701413206d\` ON \`user_list_items\``);
|
||||
await queryRunner.query(`DROP INDEX \`IDX_dca36cb201077233743d7355d2\` ON \`list_templates\``);
|
||||
await queryRunner.query(`DROP INDEX \`IDX_82feb6202f10c7f7283d398014\` ON \`list_template_items\``);
|
||||
await queryRunner.query(`ALTER TABLE \`users\` DROP INDEX \`IDX_945333aaddfc5b9021b2ee94d5\``);
|
||||
await queryRunner.query(`ALTER TABLE \`users\` DROP INDEX \`IDX_97672ac88f789774dd47f7c8be\``);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX \`IDX_users_verification_token\` ON \`users\` (\`verificationToken\`)`);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX \`IDX_users_email\` ON \`users\` (\`email\`)`);
|
||||
await queryRunner.query(`CREATE INDEX \`IDX_refresh_tokens_user_id\` ON \`refresh_tokens\` (\`userId\`)`);
|
||||
await queryRunner.query(`CREATE INDEX \`IDX_user_lists_owner_id\` ON \`user_lists\` (\`ownerId\`)`);
|
||||
await queryRunner.query(`CREATE INDEX \`IDX_user_list_items_list_id\` ON \`user_list_items\` (\`listId\`)`);
|
||||
await queryRunner.query(`CREATE INDEX \`IDX_list_templates_owner_id\` ON \`list_templates\` (\`ownerId\`)`);
|
||||
await queryRunner.query(`CREATE INDEX \`IDX_list_template_items_template_id\` ON \`list_template_items\` (\`templateId\`)`);
|
||||
await queryRunner.query(`ALTER TABLE \`list_template_seeds\` ADD CONSTRAINT \`FK_list_template_seeds_owner_id\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE \`refresh_tokens\` ADD CONSTRAINT \`FK_refresh_tokens_user_id\` FOREIGN KEY (\`userId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE \`user_lists\` ADD CONSTRAINT \`FK_user_lists_owner_id\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE \`user_list_items\` ADD CONSTRAINT \`FK_user_list_items_list_id\` FOREIGN KEY (\`listId\`) REFERENCES \`user_lists\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE \`list_templates\` ADD CONSTRAINT \`FK_list_templates_owner_id\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE \`list_template_items\` ADD CONSTRAINT \`FK_list_template_items_template_id\` FOREIGN KEY (\`templateId\`) REFERENCES \`list_templates\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`refresh_tokens\` DROP FOREIGN KEY \`FK_610102b60fea1455310ccd299de\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`user_lists\` DROP FOREIGN KEY \`FK_20f32f84af2f8a3aa60d7023260\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`user_list_items\` DROP FOREIGN KEY \`FK_7dc61846f78234b1701413206df\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`list_templates\` DROP FOREIGN KEY \`FK_dca36cb201077233743d7355d21\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`list_template_items\` DROP FOREIGN KEY \`FK_82feb6202f10c7f7283d3980144\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_610102b60fea1455310ccd299d\` ON \`refresh_tokens\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_20f32f84af2f8a3aa60d702326\` ON \`user_lists\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_7dc61846f78234b1701413206d\` ON \`user_list_items\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_dca36cb201077233743d7355d2\` ON \`list_templates\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_82feb6202f10c7f7283d398014\` ON \`list_template_items\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`users\` DROP INDEX \`IDX_945333aaddfc5b9021b2ee94d5\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`users\` DROP INDEX \`IDX_97672ac88f789774dd47f7c8be\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX \`IDX_users_verification_token\` ON \`users\` (\`verificationToken\`)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX \`IDX_users_email\` ON \`users\` (\`email\`)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX \`IDX_refresh_tokens_user_id\` ON \`refresh_tokens\` (\`userId\`)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX \`IDX_user_lists_owner_id\` ON \`user_lists\` (\`ownerId\`)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX \`IDX_user_list_items_list_id\` ON \`user_list_items\` (\`listId\`)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX \`IDX_list_templates_owner_id\` ON \`list_templates\` (\`ownerId\`)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX \`IDX_list_template_items_template_id\` ON \`list_template_items\` (\`templateId\`)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`list_template_seeds\` ADD CONSTRAINT \`FK_list_template_seeds_owner_id\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`refresh_tokens\` ADD CONSTRAINT \`FK_refresh_tokens_user_id\` FOREIGN KEY (\`userId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`user_lists\` ADD CONSTRAINT \`FK_user_lists_owner_id\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`user_list_items\` ADD CONSTRAINT \`FK_user_list_items_list_id\` FOREIGN KEY (\`listId\`) REFERENCES \`user_lists\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`list_templates\` ADD CONSTRAINT \`FK_list_templates_owner_id\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`list_template_items\` ADD CONSTRAINT \`FK_list_template_items_template_id\` FOREIGN KEY (\`templateId\`) REFERENCES \`list_templates\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserOnboardingCompleted1781000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class AddUserOnboardingCompleted1781000000000 implements MigrationInterface {
|
||||
name = 'AddUserOnboardingCompleted1781000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class GeneratedMigration1781003163444 implements MigrationInterface {
|
||||
name = 'GeneratedMigration1781003163444'
|
||||
name = 'GeneratedMigration1781003163444';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`users\` ADD \`onboardingCompleted\` tinyint NOT NULL DEFAULT 0`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`users\` ADD \`onboardingCompleted\` tinyint NOT NULL DEFAULT 0`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`users\` DROP COLUMN \`onboardingCompleted\``);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`users\` DROP COLUMN \`onboardingCompleted\``,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class GeneratedMigration1781093004780 implements MigrationInterface {
|
||||
name = 'GeneratedMigration1781093004780'
|
||||
name = 'GeneratedMigration1781093004780';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TABLE \`audit_logs\` (\`id\` varchar(36) NOT NULL, \`actorUserId\` varchar(36) NULL, \`actorEmail\` varchar(320) NULL, \`action\` varchar(100) NOT NULL, \`entityType\` varchar(80) NULL, \`entityId\` varchar(36) NULL, \`metadata\` json NULL, \`ipAddress\` varchar(64) NULL, \`userAgent\` varchar(512) NULL, \`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), INDEX \`IDX_audit_logs_actor_user_id\` (\`actorUserId\`), INDEX \`IDX_audit_logs_action\` (\`action\`), INDEX \`IDX_audit_logs_entity\` (\`entityType\`), INDEX \`IDX_audit_logs_created_at\` (\`createdAt\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`audit_logs\` (\`id\` varchar(36) NOT NULL, \`actorUserId\` varchar(36) NULL, \`actorEmail\` varchar(320) NULL, \`action\` varchar(100) NOT NULL, \`entityType\` varchar(80) NULL, \`entityId\` varchar(36) NULL, \`metadata\` json NULL, \`ipAddress\` varchar(64) NULL, \`userAgent\` varchar(512) NULL, \`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), INDEX \`IDX_audit_logs_actor_user_id\` (\`actorUserId\`), INDEX \`IDX_audit_logs_action\` (\`action\`), INDEX \`IDX_audit_logs_entity\` (\`entityType\`), INDEX \`IDX_audit_logs_created_at\` (\`createdAt\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX \`IDX_audit_logs_created_at\` ON \`audit_logs\``);
|
||||
await queryRunner.query(`DROP INDEX \`IDX_audit_logs_entity\` ON \`audit_logs\``);
|
||||
await queryRunner.query(`DROP INDEX \`IDX_audit_logs_action\` ON \`audit_logs\``);
|
||||
await queryRunner.query(`DROP INDEX \`IDX_audit_logs_actor_user_id\` ON \`audit_logs\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_audit_logs_created_at\` ON \`audit_logs\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_audit_logs_entity\` ON \`audit_logs\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_audit_logs_action\` ON \`audit_logs\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_audit_logs_actor_user_id\` ON \`audit_logs\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`audit_logs\``);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddSoftDeleteToListsAndTemplates1781300000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class AddSoftDeleteToListsAndTemplates1781300000000 implements MigrationInterface {
|
||||
name = 'AddSoftDeleteToListsAndTemplates1781300000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
@@ -27,7 +25,9 @@ export class AddSoftDeleteToListsAndTemplates1781300000000
|
||||
await queryRunner.query(
|
||||
'DROP INDEX `IDX_user_lists_deleted_at` ON `user_lists`',
|
||||
);
|
||||
await queryRunner.query('ALTER TABLE `list_templates` DROP COLUMN `deletedAt`');
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE `list_templates` DROP COLUMN `deletedAt`',
|
||||
);
|
||||
await queryRunner.query('ALTER TABLE `user_lists` DROP COLUMN `deletedAt`');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ export class AddListReminderAt1781400000000 implements MigrationInterface {
|
||||
await queryRunner.query(
|
||||
'DROP INDEX `IDX_user_lists_reminder_at` ON `user_lists`',
|
||||
);
|
||||
await queryRunner.query('ALTER TABLE `user_lists` DROP COLUMN `reminderAt`');
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE `user_lists` DROP COLUMN `reminderAt`',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateListTemplateShares1781500000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class CreateListTemplateShares1781500000000 implements MigrationInterface {
|
||||
name = 'CreateListTemplateShares1781500000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'CREATE TABLE `list_template_shares` (`id` varchar(36) NOT NULL, `templateId` varchar(36) NOT NULL, `userId` varchar(36) NOT NULL, `role` varchar(32) NOT NULL DEFAULT \'collaborator\', `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), INDEX `IDX_list_template_shares_template_id` (`templateId`), INDEX `IDX_list_template_shares_user_id` (`userId`), UNIQUE INDEX `IDX_list_template_shares_template_user` (`templateId`, `userId`), PRIMARY KEY (`id`)) ENGINE=InnoDB',
|
||||
"CREATE TABLE `list_template_shares` (`id` varchar(36) NOT NULL, `templateId` varchar(36) NOT NULL, `userId` varchar(36) NOT NULL, `role` varchar(32) NOT NULL DEFAULT 'collaborator', `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), INDEX `IDX_list_template_shares_template_id` (`templateId`), INDEX `IDX_list_template_shares_user_id` (`userId`), UNIQUE INDEX `IDX_list_template_shares_template_user` (`templateId`, `userId`), PRIMARY KEY (`id`)) ENGINE=InnoDB",
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE `list_template_shares` ADD CONSTRAINT `FK_list_template_shares_template_id` FOREIGN KEY (`templateId`) REFERENCES `list_templates`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION',
|
||||
|
||||
@@ -33,7 +33,9 @@ export class AddMcpApiKeyToUsers1781600000000 implements MigrationInterface {
|
||||
await queryRunner.query(
|
||||
'DROP INDEX `IDX_users_mcp_api_key_hash` ON `users`',
|
||||
);
|
||||
await queryRunner.query('ALTER TABLE `users` DROP COLUMN `mcpApiKeyHash`');
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE `users` DROP COLUMN `mcpApiKeyHash`',
|
||||
);
|
||||
}
|
||||
|
||||
if (await queryRunner.hasColumn('users', 'mcpApiKeyCreatedAt')) {
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateUserKeycloakGroups1782400000000 implements MigrationInterface {
|
||||
name = 'CreateUserKeycloakGroups1782400000000';
|
||||
export class CreateUserOidcGroups1782400000000 implements MigrationInterface {
|
||||
name = 'CreateUserOidcGroups1782400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasTable('user_keycloak_groups')) {
|
||||
if (await queryRunner.hasTable('user_oidc_groups')) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE \`user_keycloak_groups\` (
|
||||
CREATE TABLE \`user_oidc_groups\` (
|
||||
\`id\` varchar(36) NOT NULL,
|
||||
\`userId\` varchar(36) NOT NULL,
|
||||
\`groupPath\` varchar(255) NOT NULL,
|
||||
\`groupName\` varchar(160) NOT NULL,
|
||||
\`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
\`updatedAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
INDEX \`IDX_user_keycloak_groups_userId\` (\`userId\`),
|
||||
UNIQUE INDEX \`IDX_user_keycloak_groups_user_group\` (\`userId\`, \`groupPath\`),
|
||||
CONSTRAINT \`FK_user_keycloak_groups_user\`
|
||||
INDEX \`IDX_user_oidc_groups_userId\` (\`userId\`),
|
||||
UNIQUE INDEX \`IDX_user_oidc_groups_user_group\` (\`userId\`, \`groupPath\`),
|
||||
CONSTRAINT \`FK_user_oidc_groups_user\`
|
||||
FOREIGN KEY (\`userId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE,
|
||||
PRIMARY KEY (\`id\`)
|
||||
) ENGINE=InnoDB
|
||||
@@ -26,8 +26,8 @@ export class CreateUserKeycloakGroups1782400000000 implements MigrationInterface
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasTable('user_keycloak_groups')) {
|
||||
await queryRunner.query('DROP TABLE `user_keycloak_groups`');
|
||||
if (await queryRunner.hasTable('user_oidc_groups')) {
|
||||
await queryRunner.query('DROP TABLE `user_oidc_groups`');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateAppRolesAndPermissions1782500000000 implements MigrationInterface {
|
||||
name = 'CreateAppRolesAndPermissions1782500000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasTable('app_roles'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE \`app_roles\` (
|
||||
\`role\` varchar(80) NOT NULL,
|
||||
\`label\` varchar(160) NOT NULL,
|
||||
\`description\` varchar(255) NULL,
|
||||
\`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (\`role\`)
|
||||
) ENGINE=InnoDB
|
||||
`);
|
||||
}
|
||||
|
||||
if (!(await queryRunner.hasTable('app_permissions'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE \`app_permissions\` (
|
||||
\`permission\` varchar(120) NOT NULL,
|
||||
\`label\` varchar(160) NOT NULL,
|
||||
\`description\` varchar(255) NULL,
|
||||
\`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (\`permission\`)
|
||||
) ENGINE=InnoDB
|
||||
`);
|
||||
}
|
||||
|
||||
if (!(await queryRunner.hasTable('app_role_permissions'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE \`app_role_permissions\` (
|
||||
\`id\` varchar(36) NOT NULL,
|
||||
\`role\` varchar(80) NOT NULL,
|
||||
\`permission\` varchar(120) NOT NULL,
|
||||
\`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
INDEX \`IDX_app_role_permissions_role\` (\`role\`),
|
||||
INDEX \`IDX_app_role_permissions_permission\` (\`permission\`),
|
||||
UNIQUE INDEX \`IDX_app_role_permissions_role_permission\` (\`role\`, \`permission\`),
|
||||
CONSTRAINT \`FK_app_role_permissions_role\`
|
||||
FOREIGN KEY (\`role\`) REFERENCES \`app_roles\`(\`role\`) ON DELETE CASCADE,
|
||||
CONSTRAINT \`FK_app_role_permissions_permission\`
|
||||
FOREIGN KEY (\`permission\`) REFERENCES \`app_permissions\`(\`permission\`) ON DELETE CASCADE,
|
||||
PRIMARY KEY (\`id\`)
|
||||
) ENGINE=InnoDB
|
||||
`);
|
||||
}
|
||||
|
||||
if (!(await queryRunner.hasTable('oidc_group_role_mappings'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE \`oidc_group_role_mappings\` (
|
||||
\`id\` varchar(36) NOT NULL,
|
||||
\`groupPath\` varchar(255) NOT NULL,
|
||||
\`role\` varchar(80) NOT NULL,
|
||||
\`enabled\` tinyint NOT NULL DEFAULT 1,
|
||||
\`reason\` varchar(255) NULL,
|
||||
\`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
\`updatedAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
INDEX \`IDX_oidc_group_role_mappings_groupPath\` (\`groupPath\`),
|
||||
INDEX \`IDX_oidc_group_role_mappings_role\` (\`role\`),
|
||||
UNIQUE INDEX \`IDX_oidc_group_role_mappings_group_role\` (\`groupPath\`, \`role\`),
|
||||
CONSTRAINT \`FK_oidc_group_role_mappings_role\`
|
||||
FOREIGN KEY (\`role\`) REFERENCES \`app_roles\`(\`role\`) ON DELETE CASCADE,
|
||||
PRIMARY KEY (\`id\`)
|
||||
) ENGINE=InnoDB
|
||||
`);
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT IGNORE INTO \`app_roles\` (\`role\`, \`label\`, \`description\`) VALUES
|
||||
('app_user', 'User', 'Basiszugriff fuer angemeldete Benutzer'),
|
||||
('app_admin', 'Admin', 'Voller App-Zugriff inklusive Diagnosefunktionen')
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT IGNORE INTO \`app_permissions\` (\`permission\`, \`label\`, \`description\`) VALUES
|
||||
('dashboard.view', 'Dashboard ansehen', 'Dashboard lesen'),
|
||||
('lists.manage_own', 'Eigene Listen verwalten', 'Eigene und geteilte Listen nutzen'),
|
||||
('templates.manage_own', 'Eigene Templates verwalten', 'Eigene und geteilte Templates nutzen'),
|
||||
('tasks.manage_own', 'Eigene Tasks verwalten', 'Eigene Tasks nutzen'),
|
||||
('assistant.chat', 'Assistant Chat nutzen', 'Assistant Chat verwenden'),
|
||||
('assistant.logs.view', 'Assistant Logs ansehen', 'Assistant Chat Logs ansehen'),
|
||||
('account.manage_self', 'Eigenes Konto verwalten', 'Eigene Konto-Einstellungen verwalten'),
|
||||
('users.search', 'Benutzer suchen', 'Benutzer fuer Freigaben suchen')
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT IGNORE INTO \`app_role_permissions\` (\`id\`, \`role\`, \`permission\`) VALUES
|
||||
('rp-user-dashboard', 'app_user', 'dashboard.view'),
|
||||
('rp-user-lists', 'app_user', 'lists.manage_own'),
|
||||
('rp-user-templates', 'app_user', 'templates.manage_own'),
|
||||
('rp-user-tasks', 'app_user', 'tasks.manage_own'),
|
||||
('rp-user-assistant-chat', 'app_user', 'assistant.chat'),
|
||||
('rp-user-account', 'app_user', 'account.manage_self'),
|
||||
('rp-user-search', 'app_user', 'users.search'),
|
||||
('rp-admin-dashboard', 'app_admin', 'dashboard.view'),
|
||||
('rp-admin-lists', 'app_admin', 'lists.manage_own'),
|
||||
('rp-admin-templates', 'app_admin', 'templates.manage_own'),
|
||||
('rp-admin-tasks', 'app_admin', 'tasks.manage_own'),
|
||||
('rp-admin-assistant-chat', 'app_admin', 'assistant.chat'),
|
||||
('rp-admin-assistant-logs', 'app_admin', 'assistant.logs.view'),
|
||||
('rp-admin-account', 'app_admin', 'account.manage_self'),
|
||||
('rp-admin-search', 'app_admin', 'users.search')
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasTable('oidc_group_role_mappings')) {
|
||||
await queryRunner.query('DROP TABLE `oidc_group_role_mappings`');
|
||||
}
|
||||
|
||||
if (await queryRunner.hasTable('app_role_permissions')) {
|
||||
await queryRunner.query('DROP TABLE `app_role_permissions`');
|
||||
}
|
||||
|
||||
if (await queryRunner.hasTable('app_permissions')) {
|
||||
await queryRunner.query('DROP TABLE `app_permissions`');
|
||||
}
|
||||
|
||||
if (await queryRunner.hasTable('app_roles')) {
|
||||
await queryRunner.query('DROP TABLE `app_roles`');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,16 +38,21 @@ describe('ListTemplatesService', () => {
|
||||
it('keeps seeded templates editable and deleted templates deleted', async () => {
|
||||
const template = (await service.listTemplates('user-1'))[0];
|
||||
|
||||
const updatedTemplate = await service.updateTemplate('user-1', template.id, {
|
||||
const updatedTemplate = await service.updateTemplate(
|
||||
'user-1',
|
||||
template.id,
|
||||
{
|
||||
name: 'Meine Vorlage',
|
||||
});
|
||||
},
|
||||
);
|
||||
await service.deleteTemplate('user-1', template.id);
|
||||
|
||||
expect(updatedTemplate.name).toBe('Meine Vorlage');
|
||||
await expect(service.listTemplates('user-1')).resolves.toHaveLength(2);
|
||||
expect(
|
||||
(await service.listTemplates('user-1'))
|
||||
.some((existingTemplate) => existingTemplate.id === template.id),
|
||||
(await service.listTemplates('user-1')).some(
|
||||
(existingTemplate) => existingTemplate.id === template.id,
|
||||
),
|
||||
).toBe(false);
|
||||
await expect(service.getTemplate('user-1', template.id)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
@@ -79,12 +84,14 @@ describe('ListTemplatesService', () => {
|
||||
expect(template.items).toHaveLength(2);
|
||||
expect(template.items[0].title).toBe('Pass');
|
||||
expect(
|
||||
(await service.listTemplates('user-1'))
|
||||
.some((existingTemplate) => existingTemplate.id === template.id),
|
||||
(await service.listTemplates('user-1')).some(
|
||||
(existingTemplate) => existingTemplate.id === template.id,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
(await service.listTemplates('user-2'))
|
||||
.some((existingTemplate) => existingTemplate.id === template.id),
|
||||
(await service.listTemplates('user-2')).some(
|
||||
(existingTemplate) => existingTemplate.id === template.id,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -104,7 +111,10 @@ describe('ListTemplatesService', () => {
|
||||
const sharedTemplate = await service.shareTemplate('user-1', template.id, {
|
||||
userId: 'user-2',
|
||||
});
|
||||
const collaboratorTemplate = await service.getTemplate('user-2', template.id);
|
||||
const collaboratorTemplate = await service.getTemplate(
|
||||
'user-2',
|
||||
template.id,
|
||||
);
|
||||
const updatedByCollaborator = await service.addItem('user-2', template.id, {
|
||||
title: 'Vom Collaborator',
|
||||
});
|
||||
@@ -142,9 +152,13 @@ describe('ListTemplatesService', () => {
|
||||
});
|
||||
const itemId = template.items[0].id;
|
||||
|
||||
const updatedTemplate = await service.updateTemplate('user-1', template.id, {
|
||||
const updatedTemplate = await service.updateTemplate(
|
||||
'user-1',
|
||||
template.id,
|
||||
{
|
||||
name: 'Wocheneinkauf',
|
||||
});
|
||||
},
|
||||
);
|
||||
const updatedItemTemplate = await service.updateItem(
|
||||
'user-1',
|
||||
template.id,
|
||||
@@ -196,13 +210,17 @@ describe('ListTemplatesService', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const reorderedTemplate = await service.reorderItems('user-1', template.id, {
|
||||
const reorderedTemplate = await service.reorderItems(
|
||||
'user-1',
|
||||
template.id,
|
||||
{
|
||||
itemIds: [
|
||||
template.items[2].id,
|
||||
template.items[0].id,
|
||||
template.items[1].id,
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
const reloadedTemplate = await service.getTemplate('user-1', template.id);
|
||||
|
||||
expect(reorderedTemplate.items.map((item) => item.title)).toEqual([
|
||||
@@ -211,9 +229,7 @@ describe('ListTemplatesService', () => {
|
||||
'Zweiter Schritt',
|
||||
]);
|
||||
expect(reloadedTemplate.items.map((item) => item.position)).toEqual([
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
0, 1, 2,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -233,9 +249,7 @@ describe('ListTemplatesService', () => {
|
||||
it('rejects invalid input and missing resources', async () => {
|
||||
await expect(
|
||||
service.createTemplate('user-1', { name: ' ' }),
|
||||
).rejects.toThrow(
|
||||
'List template name is required.',
|
||||
);
|
||||
).rejects.toThrow('List template name is required.');
|
||||
await expect(
|
||||
service.createTemplate('user-1', {
|
||||
name: 'Ungueltig',
|
||||
|
||||
@@ -90,7 +90,9 @@ export class ListTemplatesService {
|
||||
});
|
||||
const sharedTemplateShares = await this.templateSharesRepository.find({
|
||||
where: { userId: ownerId },
|
||||
relations: { template: { items: true, owner: true, shares: { user: true } } },
|
||||
relations: {
|
||||
template: { items: true, owner: true, shares: { user: true } },
|
||||
},
|
||||
});
|
||||
const templatesById = new Map<string, ListTemplateEntity>();
|
||||
|
||||
@@ -210,7 +212,9 @@ export class ListTemplatesService {
|
||||
const targetUserId = this.requireShareUserId(shareDto.userId);
|
||||
|
||||
if (targetUserId === ownerId) {
|
||||
throw new BadRequestException('Template owner cannot be added as collaborator.');
|
||||
throw new BadRequestException(
|
||||
'Template owner cannot be added as collaborator.',
|
||||
);
|
||||
}
|
||||
|
||||
const targetUser = await this.usersRepository.findOne({
|
||||
@@ -365,7 +369,9 @@ export class ListTemplatesService {
|
||||
}
|
||||
|
||||
if (itemIds.length !== template.items.length) {
|
||||
throw new BadRequestException('Item ids must include every template item.');
|
||||
throw new BadRequestException(
|
||||
'Item ids must include every template item.',
|
||||
);
|
||||
}
|
||||
|
||||
const uniqueItemIds = new Set(itemIds);
|
||||
@@ -378,7 +384,9 @@ export class ListTemplatesService {
|
||||
const item = itemsById.get(itemId);
|
||||
|
||||
if (!item) {
|
||||
throw new BadRequestException('Item ids must include every template item.');
|
||||
throw new BadRequestException(
|
||||
'Item ids must include every template item.',
|
||||
);
|
||||
}
|
||||
|
||||
item.position = index;
|
||||
@@ -469,7 +477,9 @@ export class ListTemplatesService {
|
||||
const template = await this.findAccessibleTemplate(ownerId, templateId);
|
||||
|
||||
if (template.ownerId !== ownerId) {
|
||||
throw new ForbiddenException('Only the template owner can perform this action.');
|
||||
throw new ForbiddenException(
|
||||
'Only the template owner can perform this action.',
|
||||
);
|
||||
}
|
||||
|
||||
return template;
|
||||
@@ -658,7 +668,10 @@ export class ListTemplatesService {
|
||||
return normalizedUserId;
|
||||
}
|
||||
|
||||
private canAccessTemplate(template: ListTemplateEntity, userId: string): boolean {
|
||||
private canAccessTemplate(
|
||||
template: ListTemplateEntity,
|
||||
userId: string,
|
||||
): boolean {
|
||||
return (
|
||||
template.ownerId === userId ||
|
||||
Boolean(template.shares?.some((share) => share.userId === userId))
|
||||
@@ -675,7 +688,8 @@ export class ListTemplatesService {
|
||||
private async hydrateTemplateAccessRelations(
|
||||
template: ListTemplateEntity,
|
||||
): Promise<void> {
|
||||
template.owner ??= (await this.usersRepository.findOne({
|
||||
template.owner ??=
|
||||
(await this.usersRepository.findOne({
|
||||
where: { id: template.ownerId },
|
||||
})) ?? undefined;
|
||||
|
||||
@@ -686,7 +700,8 @@ export class ListTemplatesService {
|
||||
template.shares = storedShares;
|
||||
|
||||
for (const share of template.shares) {
|
||||
share.user ??= (await this.usersRepository.findOne({
|
||||
share.user ??=
|
||||
(await this.usersRepository.findOne({
|
||||
where: { id: share.userId },
|
||||
})) ?? undefined;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ export type ListRealtimeEvent =
|
||||
export class ListRealtimeService {
|
||||
// In-memory SSE fanout for one API process. If the API is scaled horizontally,
|
||||
// replace this map with a shared pub/sub backend while keeping the event shape.
|
||||
private readonly channels = new Map<string, Set<Observer<ListRealtimeEvent>>>();
|
||||
private readonly channels = new Map<
|
||||
string,
|
||||
Set<Observer<ListRealtimeEvent>>
|
||||
>();
|
||||
|
||||
/**
|
||||
* Opens an owner-scoped stream. The controller authenticates the request before
|
||||
|
||||
@@ -59,8 +59,9 @@ describe('ListReminderService', () => {
|
||||
],
|
||||
},
|
||||
);
|
||||
expect((await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt)
|
||||
.toBeNull();
|
||||
expect(
|
||||
(await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt,
|
||||
).toBeNull();
|
||||
expect(listsService.publishListSnapshot).toHaveBeenCalledWith(list.id);
|
||||
});
|
||||
|
||||
@@ -73,8 +74,9 @@ describe('ListReminderService', () => {
|
||||
await service.processDueReminders(new Date('2026-06-17T09:00:00.000Z'));
|
||||
|
||||
expect(mailService.sendListReminderEmail).not.toHaveBeenCalled();
|
||||
expect((await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt)
|
||||
.toBeNull();
|
||||
expect(
|
||||
(await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the reminder when sending fails', async () => {
|
||||
@@ -90,8 +92,9 @@ describe('ListReminderService', () => {
|
||||
|
||||
await service.processDueReminders(new Date('2026-06-17T09:00:00.000Z'));
|
||||
|
||||
expect((await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt)
|
||||
.toBe(reminderAt);
|
||||
expect(
|
||||
(await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt,
|
||||
).toBe(reminderAt);
|
||||
expect(listsService.publishListSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -57,7 +57,9 @@ export class ListReminderService {
|
||||
const ownerEmail = list.owner?.email;
|
||||
|
||||
if (!ownerEmail) {
|
||||
this.logger.warn(`List reminder skipped because owner email is missing: ${list.id}`);
|
||||
this.logger.warn(
|
||||
`List reminder skipped because owner email is missing: ${list.id}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1502,7 +1502,8 @@ export class ListsService {
|
||||
required?: unknown;
|
||||
reason?: unknown;
|
||||
};
|
||||
const itemId = typeof candidate.itemId === 'string' ? candidate.itemId : '';
|
||||
const itemId =
|
||||
typeof candidate.itemId === 'string' ? candidate.itemId : '';
|
||||
const existingItem = itemsById.get(itemId);
|
||||
|
||||
if (!existingItem || seenItemIds.has(itemId)) {
|
||||
@@ -1592,7 +1593,8 @@ export class ListsService {
|
||||
keptItemId?: unknown;
|
||||
reason?: unknown;
|
||||
};
|
||||
const itemId = typeof candidate.itemId === 'string' ? candidate.itemId : '';
|
||||
const itemId =
|
||||
typeof candidate.itemId === 'string' ? candidate.itemId : '';
|
||||
const keptItemId =
|
||||
typeof candidate.keptItemId === 'string' ? candidate.keptItemId : '';
|
||||
const item = itemsById.get(itemId);
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
ListTemplate,
|
||||
UserList,
|
||||
} from '../list-templates/list-template.types';
|
||||
import { ListTemplate, UserList } from '../list-templates/list-template.types';
|
||||
import { ListTemplatesService } from '../list-templates/list-templates.service';
|
||||
import { ListsService } from '../lists/lists.service';
|
||||
import { ListSuggestionAgentService } from './list-suggestion-agent.service';
|
||||
@@ -27,9 +24,9 @@ describe('ListSuggestionAgentService', () => {
|
||||
});
|
||||
|
||||
it('suggests read-only list ideas from matching templates', async () => {
|
||||
jest.mocked(listsService.listLists).mockResolvedValue([
|
||||
list({ name: 'Urlaub: Sommerurlaub' }),
|
||||
]);
|
||||
jest
|
||||
.mocked(listsService.listLists)
|
||||
.mockResolvedValue([list({ name: 'Urlaub: Sommerurlaub' })]);
|
||||
jest.mocked(listTemplatesService.listTemplates).mockResolvedValue([
|
||||
template({
|
||||
id: 'template-1',
|
||||
|
||||
@@ -32,7 +32,10 @@ export class ListSuggestionAgentService {
|
||||
this.listTemplatesService.listTemplates(userId),
|
||||
]);
|
||||
const existingNames = new Set(lists.map((list) => this.nameKey(list.name)));
|
||||
const matchingTemplates = this.rankTemplates(templates, goal, kind).slice(0, 2);
|
||||
const matchingTemplates = this.rankTemplates(templates, goal, kind).slice(
|
||||
0,
|
||||
2,
|
||||
);
|
||||
const suggestions = matchingTemplates.map((template) =>
|
||||
this.suggestFromTemplate(template, goal, constraints, existingNames),
|
||||
);
|
||||
@@ -180,7 +183,9 @@ export class ListSuggestionAgentService {
|
||||
return 'packing';
|
||||
}
|
||||
|
||||
if (/(einkauf|shopping|supermarkt|lebensmittel|markt)/.test(normalizedGoal)) {
|
||||
if (
|
||||
/(einkauf|shopping|supermarkt|lebensmittel|markt)/.test(normalizedGoal)
|
||||
) {
|
||||
return 'shopping';
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@ describe('TaskDigestService', () => {
|
||||
sendTaskDigestEmail: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
taskPushService = {
|
||||
sendTaskDigest: jest
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
sendTaskDigest: jest.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
subscriptionCount: 1,
|
||||
sentCount: 1,
|
||||
|
||||
@@ -196,7 +196,9 @@ export class TaskPushService {
|
||||
title,
|
||||
body: parts.join(', '),
|
||||
url: payload.tasksUrl,
|
||||
tag: payload.notificationTag ?? `task-digest-${payload.slot}-${payload.date}`,
|
||||
tag:
|
||||
payload.notificationTag ??
|
||||
`task-digest-${payload.slot}-${payload.date}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,13 +19,17 @@ export class InMemoryRepository<T extends object> {
|
||||
|
||||
async save(entityOrEntities: T | T[]): Promise<T | T[]> {
|
||||
if (Array.isArray(entityOrEntities)) {
|
||||
return Promise.all(entityOrEntities.map((entity) => this.saveOne(entity)));
|
||||
return Promise.all(
|
||||
entityOrEntities.map((entity) => this.saveOne(entity)),
|
||||
);
|
||||
}
|
||||
|
||||
return this.saveOne(entityOrEntities);
|
||||
}
|
||||
|
||||
async find(options: { where?: WhereClause<T>; order?: unknown } = {}): Promise<T[]> {
|
||||
async find(
|
||||
options: { where?: WhereClause<T>; order?: unknown } = {},
|
||||
): Promise<T[]> {
|
||||
const records = [...this.records.values()].filter((record) =>
|
||||
this.matchesWhere(record, options.where),
|
||||
);
|
||||
@@ -33,7 +37,10 @@ export class InMemoryRepository<T extends object> {
|
||||
return this.applyOrder(records, options.order);
|
||||
}
|
||||
|
||||
async findOne(options: { where?: WhereClause<T>; order?: unknown }): Promise<T | null> {
|
||||
async findOne(options: {
|
||||
where?: WhereClause<T>;
|
||||
order?: unknown;
|
||||
}): Promise<T | null> {
|
||||
const [record] = await this.find(options);
|
||||
return record ?? null;
|
||||
}
|
||||
@@ -48,7 +55,9 @@ export class InMemoryRepository<T extends object> {
|
||||
|
||||
async remove(entityOrEntities: T | T[]): Promise<T | T[]> {
|
||||
if (Array.isArray(entityOrEntities)) {
|
||||
entityOrEntities.forEach((entity) => this.records.delete(this.keyOf(entity)));
|
||||
entityOrEntities.forEach((entity) =>
|
||||
this.records.delete(this.keyOf(entity)),
|
||||
);
|
||||
return entityOrEntities;
|
||||
}
|
||||
|
||||
@@ -99,8 +108,10 @@ export class InMemoryRepository<T extends object> {
|
||||
}
|
||||
|
||||
if (
|
||||
(typeof recordValue === 'number' || typeof recordValue === 'string') &&
|
||||
(typeof operatorValue === 'number' || typeof operatorValue === 'string')
|
||||
(typeof recordValue === 'number' ||
|
||||
typeof recordValue === 'string') &&
|
||||
(typeof operatorValue === 'number' ||
|
||||
typeof operatorValue === 'string')
|
||||
) {
|
||||
return recordValue <= operatorValue;
|
||||
}
|
||||
@@ -108,6 +119,14 @@ export class InMemoryRepository<T extends object> {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value instanceof FindOperator && value.type === 'in') {
|
||||
const operatorValue = value.value as unknown;
|
||||
|
||||
return (
|
||||
Array.isArray(operatorValue) && operatorValue.includes(recordValue)
|
||||
);
|
||||
}
|
||||
|
||||
return recordValue === value;
|
||||
});
|
||||
}
|
||||
@@ -125,8 +144,12 @@ export class InMemoryRepository<T extends object> {
|
||||
|
||||
if (typedOrder?.name) {
|
||||
sortedRecords.sort((left, right) => {
|
||||
const leftName = String((left as Record<string, unknown>)['name'] ?? '');
|
||||
const rightName = String((right as Record<string, unknown>)['name'] ?? '');
|
||||
const leftName = String(
|
||||
(left as Record<string, unknown>)['name'] ?? '',
|
||||
);
|
||||
const rightName = String(
|
||||
(right as Record<string, unknown>)['name'] ?? '',
|
||||
);
|
||||
return typedOrder.name === 'DESC'
|
||||
? rightName.localeCompare(leftName)
|
||||
: leftName.localeCompare(rightName);
|
||||
|
||||
@@ -38,15 +38,18 @@ describe('AppController (e2e)', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
oidcService = {
|
||||
createAuthorizationUrl: jest.fn(
|
||||
async () => 'https://sso.example.test/authorize',
|
||||
createAuthorizationUrl: jest.fn(() =>
|
||||
Promise.resolve('https://sso.example.test/authorize'),
|
||||
),
|
||||
exchangeCallback: jest.fn(async () => ({
|
||||
exchangeCallback: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
subject: 'oidc-default',
|
||||
email: 'default@example.com',
|
||||
name: 'Default User',
|
||||
groups: [],
|
||||
})),
|
||||
idToken: 'id-token',
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
@@ -219,6 +222,7 @@ describe('AppController (e2e)', () => {
|
||||
email,
|
||||
name: 'Test User',
|
||||
groups: [],
|
||||
idToken: 'id-token',
|
||||
});
|
||||
|
||||
const exchangeResponse = await request(app.getHttpServer())
|
||||
@@ -226,7 +230,7 @@ describe('AppController (e2e)', () => {
|
||||
.send({ code: 'code', state: 'state' })
|
||||
.expect(200);
|
||||
|
||||
return exchangeResponse.body as unknown as AuthResponseBody;
|
||||
return exchangeResponse.body as AuthResponseBody;
|
||||
}
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
@@ -234,17 +238,19 @@ describe('AppController (e2e)', () => {
|
||||
}
|
||||
|
||||
async function ensureSsoSchema(dataSource: DataSource): Promise<void> {
|
||||
const usersTables = (await dataSource.query(
|
||||
const usersTables = await dataSource.query<Array<Record<string, unknown>>>(
|
||||
"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(
|
||||
const oidcSubjectColumns = await dataSource.query<
|
||||
Array<Record<string, unknown>>
|
||||
>(
|
||||
"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(
|
||||
@@ -264,10 +270,10 @@ describe('AppController (e2e)', () => {
|
||||
dataSource: DataSource,
|
||||
columnName: string,
|
||||
): Promise<void> {
|
||||
const columns = (await dataSource.query(
|
||||
const columns = await dataSource.query<Array<Record<string, unknown>>>(
|
||||
'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(
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section class="groups-section" aria-label="Keycloak Gruppen">
|
||||
<section class="groups-section" aria-label="OIDC Gruppen">
|
||||
<div class="settings-heading">
|
||||
<mat-icon aria-hidden="true">groups</mat-icon>
|
||||
<div>
|
||||
<h2>Keycloak-Gruppen</h2>
|
||||
<h2>OIDC-Gruppen</h2>
|
||||
<p>{{ auth.user()?.groups?.length || 0 }} synchronisiert</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -38,6 +38,44 @@
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="access-section" aria-label="App Rechte">
|
||||
<div class="settings-heading">
|
||||
<mat-icon aria-hidden="true">admin_panel_settings</mat-icon>
|
||||
<div>
|
||||
<h2>App-Rollen</h2>
|
||||
<p>{{ auth.user()?.roles?.length || 0 }} aktiv</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (auth.user()?.roles?.length) {
|
||||
<ul class="group-list">
|
||||
@for (role of auth.user()?.roles ?? []; track role) {
|
||||
<li>{{ role }}</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="settings-description">Keine App-Rollen hinterlegt.</p>
|
||||
}
|
||||
|
||||
<div class="settings-heading compact-heading">
|
||||
<mat-icon aria-hidden="true">key</mat-icon>
|
||||
<div>
|
||||
<h2>App-Rechte</h2>
|
||||
<p>{{ auth.user()?.permissions?.length || 0 }} aktiv</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (auth.user()?.permissions?.length) {
|
||||
<ul class="group-list">
|
||||
@for (permission of auth.user()?.permissions ?? []; track permission) {
|
||||
<li>{{ permission }}</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="settings-description">Keine App-Rechte hinterlegt.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="settings-section" aria-label="Task-Mail Einstellungen">
|
||||
<div class="settings-heading">
|
||||
<mat-icon aria-hidden="true">mark_email_unread</mat-icon>
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface));
|
||||
}
|
||||
|
||||
.groups-section {
|
||||
.groups-section,
|
||||
.access-section {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
margin-top: 1rem;
|
||||
@@ -50,6 +51,10 @@
|
||||
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface));
|
||||
}
|
||||
|
||||
.compact-heading {
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
.settings-heading {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@@ -30,7 +29,6 @@ export class AccountComponent {
|
||||
protected readonly auth = inject(AuthService);
|
||||
protected readonly onboarding = inject(OnboardingService);
|
||||
protected readonly taskPush = inject(TaskPushService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
protected savingTaskDigestPreference = false;
|
||||
protected readonly taskDigestPreferenceOptions: ReadonlyArray<{
|
||||
@@ -116,7 +114,6 @@ export class AccountComponent {
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.auth.logout();
|
||||
void this.router.navigateByUrl('/login');
|
||||
this.auth.logoutThroughProvider();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
<mat-icon matListItemIcon aria-hidden="true">account_circle</mat-icon>
|
||||
<span matListItemTitle>Account</span>
|
||||
</a>
|
||||
@if (auth.hasPermission('assistant.logs.view')) {
|
||||
<a
|
||||
mat-list-item
|
||||
routerLink="/assistant/logs"
|
||||
@@ -115,6 +116,7 @@
|
||||
<mat-icon matListItemIcon aria-hidden="true">manage_search</mat-icon>
|
||||
<span matListItemTitle>Assistant Logs</span>
|
||||
</a>
|
||||
}
|
||||
</mat-nav-list>
|
||||
</nav>
|
||||
</mat-sidenav>
|
||||
@@ -128,7 +130,9 @@
|
||||
</mat-sidenav-content>
|
||||
</mat-sidenav-container>
|
||||
|
||||
@if (auth.hasPermission('assistant.chat')) {
|
||||
<app-assistant-chat />
|
||||
}
|
||||
|
||||
<nav class="bottom-nav" aria-label="Mobile Hauptnavigation">
|
||||
<a
|
||||
|
||||
@@ -70,6 +70,7 @@ function isAuthRequest(request: HttpRequest<unknown>): boolean {
|
||||
'/api/auth/register',
|
||||
'/api/auth/sso',
|
||||
'/api/auth/refresh',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/resend-verification',
|
||||
'/api/auth/verify-email',
|
||||
].some((publicAuthUrl) => request.url.startsWith(publicAuthUrl));
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface PublicUser {
|
||||
onboardingCompleted: boolean;
|
||||
taskDigestPreference: TaskDigestPreference;
|
||||
groups: string[];
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface PublicUserSearchResult {
|
||||
@@ -18,9 +20,14 @@ export interface PublicUserSearchResult {
|
||||
export interface AuthTokenResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
idToken?: string;
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
export interface AuthLogoutResponse {
|
||||
logoutUrl: string;
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
message: string;
|
||||
user: PublicUser;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { Observable, finalize, shareReplay, tap, throwError } from 'rxjs';
|
||||
import {
|
||||
AuthLogoutResponse,
|
||||
AuthTokenResponse,
|
||||
LoginRequest,
|
||||
PublicUser,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'listify.accessToken';
|
||||
const REFRESH_TOKEN_KEY = 'listify.refreshToken';
|
||||
const ID_TOKEN_KEY = 'listify.idToken';
|
||||
const USER_KEY = 'listify.user';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -82,6 +84,18 @@ export class AuthService {
|
||||
return this.storage?.getItem(REFRESH_TOKEN_KEY) ?? null;
|
||||
}
|
||||
|
||||
idToken(): string | null {
|
||||
return this.storage?.getItem(ID_TOKEN_KEY) ?? null;
|
||||
}
|
||||
|
||||
hasPermission(permission: string): boolean {
|
||||
return this.userSignal()?.permissions.includes(permission) === true;
|
||||
}
|
||||
|
||||
hasAnyPermission(permissions: string[]): boolean {
|
||||
return permissions.some((permission) => this.hasPermission(permission));
|
||||
}
|
||||
|
||||
refreshSession(): Observable<AuthTokenResponse> {
|
||||
const refreshToken = this.refreshToken();
|
||||
|
||||
@@ -103,8 +117,31 @@ export class AuthService {
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.clearSession();
|
||||
}
|
||||
|
||||
logoutThroughProvider(): void {
|
||||
const payload = {
|
||||
refreshToken: this.refreshToken(),
|
||||
idTokenHint: this.idToken(),
|
||||
};
|
||||
|
||||
this.http.post<AuthLogoutResponse>(`${this.apiUrl}/logout`, payload).subscribe({
|
||||
next: (response) => {
|
||||
this.clearSession();
|
||||
window.location.href = response.logoutUrl;
|
||||
},
|
||||
error: () => {
|
||||
this.clearSession();
|
||||
window.location.href = '/login';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private clearSession(): void {
|
||||
this.storage?.removeItem(ACCESS_TOKEN_KEY);
|
||||
this.storage?.removeItem(REFRESH_TOKEN_KEY);
|
||||
this.storage?.removeItem(ID_TOKEN_KEY);
|
||||
this.storage?.removeItem(USER_KEY);
|
||||
this.userSignal.set(null);
|
||||
}
|
||||
@@ -112,6 +149,11 @@ export class AuthService {
|
||||
private storeSession(response: AuthTokenResponse): void {
|
||||
this.storage?.setItem(ACCESS_TOKEN_KEY, response.accessToken);
|
||||
this.storage?.setItem(REFRESH_TOKEN_KEY, response.refreshToken);
|
||||
if (response.idToken) {
|
||||
this.storage?.setItem(ID_TOKEN_KEY, response.idToken);
|
||||
} else {
|
||||
this.storage?.removeItem(ID_TOKEN_KEY);
|
||||
}
|
||||
this.storeUser(response.user);
|
||||
}
|
||||
|
||||
@@ -140,6 +182,8 @@ export class AuthService {
|
||||
return {
|
||||
...user,
|
||||
groups: Array.isArray(user.groups) ? user.groups : [],
|
||||
roles: Array.isArray(user.roles) ? user.roles : [],
|
||||
permissions: Array.isArray(user.permissions) ? user.permissions : [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export class SsoCallbackComponent implements OnInit {
|
||||
const params = new URLSearchParams(window.location.hash.replace(/^#/, ''));
|
||||
const accessToken = params.get('accessToken');
|
||||
const refreshToken = params.get('refreshToken');
|
||||
const idToken = params.get('idToken') ?? undefined;
|
||||
const userJson = params.get('user');
|
||||
|
||||
if (!accessToken || !refreshToken || !userJson) {
|
||||
@@ -92,6 +93,7 @@ export class SsoCallbackComponent implements OnInit {
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
idToken,
|
||||
user: JSON.parse(userJson) as AuthTokenResponse['user'],
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -28,3 +28,6 @@ Verfügbare MCP-Tools:
|
||||
- `add_template_item`: fügt ein Item zu einem bestehenden Template hinzu.
|
||||
|
||||
Weitere Details und Beispiel-Requests stehen in `listify-api/README.md`.
|
||||
|
||||
INSERT INTO oidc_group_role_mappings (id, groupPath, role, enabled)
|
||||
VALUES (UUID(), '/deine/oidc/gruppe', 'app_admin', 1);
|
||||
|
||||
Reference in New Issue
Block a user