Skip to content

Commit 75822f1

Browse files
authored
Merge pull request #410 from openwallet-foundation-labs/fix/289
Fix/289
2 parents 7d9c81c + 938f2ee commit 75822f1

File tree

16 files changed

+703
-10
lines changed

16 files changed

+703
-10
lines changed

apps/backend/src/auth/client/adapters/internal-clients.service.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ export class InternalClientsProvider
8484
description: e.description,
8585
tenantId,
8686
roles: e.roles,
87+
allowedPresentationConfigs: e.allowedPresentationConfigs,
88+
allowedIssuanceConfigs: e.allowedIssuanceConfigs,
8789
})),
8890
);
8991
}
@@ -96,9 +98,19 @@ export class InternalClientsProvider
9698
description: e.description,
9799
tenantId,
98100
roles: e.roles,
101+
allowedPresentationConfigs: e.allowedPresentationConfigs,
102+
allowedIssuanceConfigs: e.allowedIssuanceConfigs,
99103
}));
100104
}
101105

106+
/**
107+
* Get a client by its clientId only (without tenant context).
108+
* Used for JWT validation to fetch client restrictions.
109+
*/
110+
async getClientById(clientId: string): Promise<ClientEntity | null> {
111+
return this.repo.findOne({ where: { clientId } });
112+
}
113+
102114
getClientSecret(sub: string, id: string): Promise<string> {
103115
return this.repo
104116
.findOneByOrFail({ clientId: id, tenant: { id: sub } })

apps/backend/src/auth/client/adapters/keycloak-clients.service.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ export class KeycloakClientsProvider
101101
});
102102
}
103103

104+
/**
105+
* Get a client by its clientId only (without tenant context).
106+
* Used for JWT validation to fetch client restrictions.
107+
*/
108+
async getClientById(clientId: string): Promise<ClientEntity | null> {
109+
return this.clientRepo.findOne({ where: { clientId } });
110+
}
111+
104112
getClientSecret(_sub: string, id: string): Promise<string> {
105113
return this.kc.clients
106114
.find({ clientId: id })
@@ -173,6 +181,8 @@ export class KeycloakClientsProvider
173181
clientId: dto.clientId,
174182
description: dto.description,
175183
roles: dto.roles,
184+
allowedPresentationConfigs: dto.allowedPresentationConfigs,
185+
allowedIssuanceConfigs: dto.allowedIssuanceConfigs,
176186
tenant: { id: tenantId },
177187
});
178188
await this.clientRepo.save(entity);
@@ -182,6 +192,8 @@ export class KeycloakClientsProvider
182192
description: dto.description,
183193
tenantId,
184194
roles: dto.roles,
195+
allowedPresentationConfigs: dto.allowedPresentationConfigs,
196+
allowedIssuanceConfigs: dto.allowedIssuanceConfigs,
185197
clientSecret: secret.value,
186198
};
187199
}

apps/backend/src/auth/client/client.provider.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ export abstract class ClientsProvider {
2020
tenantId: string,
2121
clientId: string,
2222
): Promise<ClientEntity>;
23+
24+
/**
25+
* Get a client by its clientId only (without tenant context).
26+
* Used for JWT validation where we need to fetch the client's restrictions.
27+
* @param clientId The client ID (may be namespaced with tenant prefix for Keycloak)
28+
* @returns The client entity or null if not found
29+
*/
30+
abstract getClientById(clientId: string): Promise<ClientEntity | null>;
31+
2332
abstract addClient(
2433
tenantId: string,
2534
dto: CreateClientDto,

apps/backend/src/auth/client/entities/client.entity.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { IsEnum, IsOptional, IsString } from "class-validator";
1+
import { ApiPropertyOptional } from "@nestjs/swagger";
2+
import { IsArray, IsEnum, IsOptional, IsString } from "class-validator";
23
import { Column, Entity, ManyToOne, PrimaryColumn } from "typeorm";
34
import { Role } from "../../roles/role.enum";
45
import { TenantEntity } from "../../tenant/entitites/tenant.entity";
@@ -19,6 +20,7 @@ export class ClientEntity {
1920
* The secret key for the client.
2021
*/
2122
@IsString()
23+
@IsOptional()
2224
@Column({ nullable: true })
2325
secret?: string;
2426

@@ -43,6 +45,40 @@ export class ClientEntity {
4345
@Column({ type: "json" })
4446
roles!: Role[];
4547

48+
/**
49+
* Optional list of presentation config IDs this client is allowed to use.
50+
* If null or empty, the client can use all presentation configs (backward compatible).
51+
* Only relevant if the client has the 'presentation:offer' role.
52+
*/
53+
@ApiPropertyOptional({
54+
type: [String],
55+
description:
56+
"List of presentation config IDs this client can use. If empty/null, all configs are allowed.",
57+
example: ["age-verification", "kyc-basic"],
58+
})
59+
@IsOptional()
60+
@IsArray()
61+
@IsString({ each: true })
62+
@Column({ type: "json", nullable: true })
63+
allowedPresentationConfigs?: string[] | null;
64+
65+
/**
66+
* Optional list of issuance config IDs this client is allowed to use.
67+
* If null or empty, the client can use all issuance configs (backward compatible).
68+
* Only relevant if the client has the 'issuance:offer' role.
69+
*/
70+
@ApiPropertyOptional({
71+
type: [String],
72+
description:
73+
"List of issuance config IDs this client can use. If empty/null, all configs are allowed.",
74+
example: ["pid", "mdl"],
75+
})
76+
@IsOptional()
77+
@IsArray()
78+
@IsString({ each: true })
79+
@Column({ type: "json", nullable: true })
80+
allowedIssuanceConfigs?: string[] | null;
81+
4682
/**
4783
* The tenant that the client belongs to.
4884
*/

apps/backend/src/auth/jwt.strategy.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1-
import { Injectable } from "@nestjs/common";
1+
import { Inject, Injectable } from "@nestjs/common";
22
import { ConfigService } from "@nestjs/config";
33
import { PassportStrategy } from "@nestjs/passport";
44
import { passportJwtSecret } from "jwks-rsa";
55
import { ExtractJwt, Strategy } from "passport-jwt";
6+
import { CLIENTS_PROVIDER, ClientsProvider } from "./client/client.provider";
67
import { TenantService } from "./tenant/tenant.service";
7-
import { InternalTokenPayload } from "./token.decorator";
8+
import { InternalTokenPayload, TokenPayload } from "./token.decorator";
89

910
@Injectable()
1011
export class JwtStrategy extends PassportStrategy(Strategy, "jwt") {
1112
constructor(
1213
private configService: ConfigService,
1314
private tenantService: TenantService,
15+
@Inject(CLIENTS_PROVIDER)
16+
private readonly clientsProvider: ClientsProvider,
1417
) {
1518
const useExternalOIDC = configService.get<boolean>("OIDC");
1619

@@ -77,10 +80,11 @@ export class JwtStrategy extends PassportStrategy(Strategy, "jwt") {
7780

7881
/**
7982
* Validate the JWT payload. It will also check if the client is set up.
83+
* Fetches client entity to include resource-level restrictions (allowedPresentationConfigs, etc.)
8084
* @param payload The JWT payload
81-
* @returns The validated payload or an error
85+
* @returns The validated payload with tenant and client info
8286
*/
83-
async validate(payload: InternalTokenPayload): Promise<any> {
87+
async validate(payload: InternalTokenPayload): Promise<TokenPayload> {
8488
const useExternalOIDC =
8589
this.configService.get<string>("OIDC") !== undefined;
8690
let sub = payload.tenant_id;
@@ -93,9 +97,21 @@ export class JwtStrategy extends PassportStrategy(Strategy, "jwt") {
9397
.getTenant(sub)
9498
.catch(() => null);
9599

100+
// Fetch client to get resource-level restrictions
101+
// For internal auth: sub is the client ID
102+
// For Keycloak: azp (authorized party) or client_id contains the client ID
103+
const clientId =
104+
payload.sub || (payload as any).azp || (payload as any).client_id;
105+
const client = clientId
106+
? await this.clientsProvider
107+
.getClientById(clientId)
108+
.catch(() => null)
109+
: null;
110+
96111
return {
97-
entity: tenantEntity,
112+
entity: tenantEntity ?? undefined,
98113
roles: payload.roles || (payload as any).realm_access?.roles || [],
114+
client: client ?? undefined,
99115
};
100116
}
101117
}

apps/backend/src/auth/token.decorator.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
2+
import { ClientEntity } from "./client/entities/client.entity";
23
import { Role } from "./roles/role.enum";
34
import { TenantEntity } from "./tenant/entitites/tenant.entity";
45

@@ -25,11 +26,21 @@ export interface TokenPayload {
2526
* Role for the user
2627
*/
2728
roles: Role[];
29+
30+
/**
31+
* Client entity (includes resource-level restrictions)
32+
*/
33+
client?: ClientEntity;
2834
}
2935

3036
export interface InternalTokenPayload extends TokenPayload {
3137
/**
3238
* Tenant ID
3339
*/
3440
tenant_id: string;
41+
42+
/**
43+
* Client ID (subject of the token)
44+
*/
45+
sub?: string;
3546
}

apps/backend/src/issuer/issuance/offer/credential-offer.controller.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { Body, Controller, Post, Res } from "@nestjs/common";
1+
import {
2+
Body,
3+
Controller,
4+
ForbiddenException,
5+
Post,
6+
Res,
7+
} from "@nestjs/common";
28
import { ApiBody, ApiProduces, ApiResponse, ApiTags } from "@nestjs/swagger";
39
import { Response } from "express";
410
import * as QRCode from "qrcode";
@@ -62,6 +68,19 @@ export class CredentialOfferController {
6268
@Body() body: OfferRequestDto,
6369
@Token() user: TokenPayload,
6470
) {
71+
// Check if client has restricted issuance configs
72+
if (user.client?.allowedIssuanceConfigs?.length) {
73+
const unauthorized = body.credentialConfigurationIds.filter(
74+
(configId) =>
75+
!user.client!.allowedIssuanceConfigs!.includes(configId),
76+
);
77+
if (unauthorized.length > 0) {
78+
throw new ForbiddenException(
79+
`Client is not authorized to use issuance config(s): ${unauthorized.join(", ")}`,
80+
);
81+
}
82+
}
83+
6584
// For now, we'll just pass the body to the service as before
6685
// You can modify the service later to accept user information if needed
6786
const values = await this.oid4vciService.createOffer(

apps/backend/src/verifier/verifier-offer/verifier-offer.controller.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { Body, Controller, Post, Req, Res } from "@nestjs/common";
1+
import {
2+
Body,
3+
Controller,
4+
ForbiddenException,
5+
Post,
6+
Req,
7+
Res,
8+
} from "@nestjs/common";
29
import { ApiBody, ApiProduces, ApiResponse, ApiTags } from "@nestjs/swagger";
310
import { Request, Response } from "express";
411
import QRCode from "qrcode";
@@ -71,6 +78,17 @@ export class VerifierOfferController {
7178
@Body() body: PresentationRequest,
7279
@Token() user: TokenPayload,
7380
) {
81+
// Check resource-level authorization
82+
if (user.client?.allowedPresentationConfigs?.length) {
83+
if (
84+
!user.client.allowedPresentationConfigs.includes(body.requestId)
85+
) {
86+
throw new ForbiddenException(
87+
`Client is not authorized to use presentation config: ${body.requestId}`,
88+
);
89+
}
90+
}
91+
7492
const values = await this.oid4vpService.createRequest(
7593
body.requestId,
7694
{

0 commit comments

Comments
 (0)