idp angepasst

This commit is contained in:
Bastian Wagner
2026-07-15 16:59:17 +02:00
parent 0dfba43aa6
commit fadb5dd049
59 changed files with 1954 additions and 367 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 : [],
};
}

View File

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

View File

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