Skip to content

Commit b85b087

Browse files
committed
refactor: Move uma_protection behavior to separate class
1 parent 9b08f27 commit b85b087

8 files changed

Lines changed: 492 additions & 377 deletions

File tree

packages/uma/config/routes/tokens.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@
1010
"handler": {
1111
"@type": "TokenRequestHandler",
1212
"negotiator": { "@id": "urn:uma:default:Negotiator" },
13-
"storage": { "@id": "urn:solid-server:default:ClientRegistrationStorage" },
14-
"keyGen": { "@id": "urn:uma:default:JwkGenerator" },
15-
"baseUrl": { "@id": "urn:uma:variables:baseUrl" }
13+
"umaProtection": {
14+
"@id": "urn:uma:default:UmaProtection",
15+
"@type": "UmaProtection",
16+
"storage": { "@id": "urn:solid-server:default:ClientRegistrationStorage" },
17+
"keyGen": { "@id": "urn:uma:default:JwkGenerator" },
18+
"baseUrl": { "@id": "urn:uma:variables:baseUrl" }
19+
}
1620
},
1721
"path": "/uma/token"
1822
}

packages/uma/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export * from './routes/Contract';
5454
export * from './routes/BaseHandler';
5555
export * from './routes/ClientRegistration';
5656
export * from './routes/Collection';
57+
export * from './routes/token/UmaProtection';
5758

5859
// Tickets
5960
export * from './ticketing/Ticket';

packages/uma/src/routes/Token.ts

Lines changed: 7 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,25 @@
1-
import {
2-
BadRequestHttpError,
3-
ForbiddenHttpError,
4-
IndexedStorage,
5-
JwkGenerator,
6-
matchesAuthorizationScheme,
7-
TypeObject,
8-
UnauthorizedHttpError
9-
} from '@solid/community-server';
1+
import { BadRequestHttpError, } from '@solid/community-server';
102
import { getLoggerFor } from 'global-logger-factory';
11-
import { importJWK, SignJWT } from 'jose';
12-
import ms, { StringValue } from 'ms';
13-
import { randomUUID } from 'node:crypto';
143
import { DialogInput } from '../dialog/Input';
154
import { Negotiator } from '../dialog/Negotiator';
165
import { NeedInfoError } from '../errors/NeedInfoError';
176
import { HttpHandler, HttpHandlerContext, HttpHandlerResponse } from '../util/http/models/HttpHandler';
187
import { reType } from '../util/ReType';
19-
import { CLIENT_REGISTRATION_STORAGE_DESCRIPTION, CLIENT_REGISTRATION_STORAGE_TYPE } from './ClientRegistration';
8+
import { UMA_PROTECTION_SCOPE } from './token/UmaProtection';
209

21-
export const GRANT_TYPE_CLIENT_CREDENTIALS = 'client_credentials';
22-
export const GRANT_TYPE_REFRESH_TOKEN = 'refresh_token';
2310
export const GRANT_TYPE_UMA_TICKET = 'urn:ietf:params:oauth:grant-type:uma-ticket';
2411

25-
export const PAT_STORAGE_TYPE = 'pat';
26-
export const PAT_STORAGE_DESCRIPTION = {
27-
pat: 'string',
28-
expiration: 'number',
29-
refreshToken: 'string',
30-
registration: `id:${CLIENT_REGISTRATION_STORAGE_TYPE}`,
31-
} as const;
32-
3312
/**
3413
* The TokenRequestHandler implements the interface of the UMA Token Endpoint.
3514
*/
3615
export class TokenRequestHandler extends HttpHandler {
3716
protected readonly logger = getLoggerFor(this);
38-
protected readonly tokenExpiration: number;
39-
private readonly storage: IndexedStorage<{
40-
[CLIENT_REGISTRATION_STORAGE_TYPE]: typeof CLIENT_REGISTRATION_STORAGE_DESCRIPTION,
41-
[PAT_STORAGE_TYPE]: typeof PAT_STORAGE_DESCRIPTION,
42-
}>;
4317

4418
constructor(
4519
protected negotiator: Negotiator,
46-
storage: IndexedStorage<Record<string, never>>,
47-
protected readonly keyGen: JwkGenerator,
48-
protected readonly baseUrl: string,
49-
tokenExpiration: string = '30m',
20+
protected readonly umaProtection: HttpHandler,
5021
) {
5122
super();
52-
this.tokenExpiration = Math.floor(ms(tokenExpiration as StringValue)/1000);
53-
this.storage = storage;
54-
this.initializeStorage();
55-
}
56-
57-
protected async initializeStorage(): Promise<void> {
58-
await this.storage.defineType(PAT_STORAGE_TYPE, PAT_STORAGE_DESCRIPTION);
59-
await this.storage.createIndex(PAT_STORAGE_TYPE, 'refreshToken');
60-
await this.storage.createIndex(PAT_STORAGE_TYPE, 'pat');
61-
await this.storage.createIndex(PAT_STORAGE_TYPE, 'registration');
6223
}
6324

6425
public async handle(input: HttpHandlerContext): Promise<HttpHandlerResponse<any>> {
@@ -71,9 +32,11 @@ export class TokenRequestHandler extends HttpHandler {
7132
throw new BadRequestHttpError(`Invalid token request body: ${e instanceof Error ? e.message : ''}`);
7233
}
7334

35+
if (params.scope === UMA_PROTECTION_SCOPE) {
36+
return this.umaProtection.handleSafe(input);
37+
}
38+
7439
switch (params.grant_type) {
75-
case GRANT_TYPE_CLIENT_CREDENTIALS: return this.handlePatRequest(params, input.request.headers.authorization);
76-
case GRANT_TYPE_REFRESH_TOKEN: return this.handleRefreshRequest(params, input.request.headers.authorization);
7740
case GRANT_TYPE_UMA_TICKET: return this.handleUmaGrant(params);
7841
default: throw new BadRequestHttpError(`Unsupported grant_type ${params.grant_type}`);
7942
}
@@ -98,88 +61,4 @@ export class TokenRequestHandler extends HttpHandler {
9861
throw e; // TODO: distinguish other errors
9962
}
10063
}
101-
102-
protected async handlePatRequest(params: DialogInput, authorization?: string): Promise<HttpHandlerResponse<any>> {
103-
const registration = await this.handlePreliminaryPatChecks(params, authorization);
104-
// If there already is a stored token: reuse the ID
105-
const matches = await this.storage.findIds(PAT_STORAGE_TYPE, { registration: registration.id });
106-
return this.generateToken(registration, matches.length > 0 ? matches[0] : undefined);
107-
}
108-
109-
protected async handleRefreshRequest(params: DialogInput, authorization?: string): Promise<HttpHandlerResponse<any>> {
110-
if (!params.refresh_token) {
111-
throw new BadRequestHttpError(`Missing refresh_token parameter`);
112-
}
113-
114-
const pats = await this.storage.find(PAT_STORAGE_TYPE, { refreshToken: params.refresh_token });
115-
if (pats.length === 0) {
116-
throw new ForbiddenHttpError(`Unknown refresh token ${params.refresh_token}`);
117-
}
118-
const registration = await this.handlePreliminaryPatChecks(params, authorization);
119-
if (registration.id !== pats[0].registration) {
120-
throw new ForbiddenHttpError(`Wrong credentials for refresh token ${params.refresh_token}`);
121-
}
122-
123-
return this.generateToken(registration, pats[0].id);
124-
}
125-
126-
// Returns the UserId if there is a match, or throws an error
127-
protected async handlePreliminaryPatChecks(params: DialogInput, authorization?: string):
128-
Promise<TypeObject<typeof CLIENT_REGISTRATION_STORAGE_DESCRIPTION>> {
129-
if (typeof authorization !== 'string') {
130-
throw new UnauthorizedHttpError();
131-
}
132-
if (params.scope !== 'uma_protection') {
133-
throw new BadRequestHttpError(`Expected scope 'uma_protection'`);
134-
}
135-
if (!matchesAuthorizationScheme('Basic', authorization)) {
136-
throw new BadRequestHttpError(`Expected scheme 'Basic'`);
137-
}
138-
const decoded = Buffer.from(authorization.split(' ')[1], 'base64').toString('utf8');
139-
const [ id, secret ] = decoded.split(':');
140-
const match = await this.storage.find(CLIENT_REGISTRATION_STORAGE_TYPE,
141-
{ clientId: decodeURIComponent(id), clientSecret: decodeURIComponent(secret ?? '') });
142-
if (match.length === 0) {
143-
throw new ForbiddenHttpError();
144-
}
145-
return match[0];
146-
}
147-
148-
protected async generateToken(registration: TypeObject<typeof CLIENT_REGISTRATION_STORAGE_DESCRIPTION>, id?: string):
149-
Promise<HttpHandlerResponse<any>> {
150-
const refresh_token = randomUUID();
151-
const expiration = Date.now() + this.tokenExpiration * 1000;
152-
const key = await this.keyGen.getPrivateKey();
153-
const jwk = await importJWK(key, key.alg);
154-
const pat = await new SignJWT({
155-
scope: 'uma_protection',
156-
azp: registration.clientId,
157-
client_id: registration.clientId
158-
}).setProtectedHeader({ alg: key.alg, kid: key.kid })
159-
.setIssuedAt()
160-
.setSubject(registration.userId)
161-
.setIssuer(this.baseUrl)
162-
.setAudience(this.baseUrl)
163-
.setExpirationTime(Math.floor(expiration/1000))
164-
.setJti(randomUUID())
165-
.sign(jwk);
166-
167-
const body = { pat, refreshToken: refresh_token, expiration, registration: registration.id };
168-
if (id) {
169-
await this.storage.set(PAT_STORAGE_TYPE, { id, ...body });
170-
} else {
171-
await this.storage.create(PAT_STORAGE_TYPE, body);
172-
}
173-
174-
return {
175-
status: 201,
176-
body: {
177-
access_token: pat,
178-
refresh_token,
179-
token_type: 'Bearer',
180-
expires_in: this.tokenExpiration,
181-
scope: 'uma_protection',
182-
}
183-
}
184-
}
18564
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import {
2+
BadRequestHttpError,
3+
createErrorMessage,
4+
ForbiddenHttpError,
5+
IndexedStorage,
6+
JwkGenerator,
7+
matchesAuthorizationScheme,
8+
TypeObject,
9+
UnauthorizedHttpError
10+
} from '@solid/community-server';
11+
import { importJWK, SignJWT } from 'jose';
12+
import ms, { StringValue } from 'ms';
13+
import { randomUUID } from 'node:crypto';
14+
import { DialogInput } from '../../dialog/Input';
15+
import { DialogOutput } from '../../dialog/Output';
16+
import { HttpHandler, HttpHandlerContext, HttpHandlerResponse } from '../../util/http/models/HttpHandler';
17+
import { reType } from '../../util/ReType';
18+
import { CLIENT_REGISTRATION_STORAGE_DESCRIPTION, CLIENT_REGISTRATION_STORAGE_TYPE } from '../ClientRegistration';
19+
20+
const GRANT_TYPE_CLIENT_CREDENTIALS = 'client_credentials';
21+
const GRANT_TYPE_REFRESH_TOKEN = 'refresh_token';
22+
23+
export const UMA_PROTECTION_SCOPE = 'uma_protection';
24+
25+
export const PAT_STORAGE_TYPE = 'pat';
26+
export const PAT_STORAGE_DESCRIPTION = {
27+
pat: 'string',
28+
expiration: 'number',
29+
refreshToken: 'string',
30+
registration: `id:${CLIENT_REGISTRATION_STORAGE_TYPE}`,
31+
} as const;
32+
33+
type Registration = TypeObject<typeof CLIENT_REGISTRATION_STORAGE_DESCRIPTION>;
34+
35+
/**
36+
* Handles the requests related to the UMA protection API, including generating and refreshing PATs.
37+
*/
38+
export class UmaProtection extends HttpHandler {
39+
protected readonly tokenExpiration: number;
40+
private readonly storage: IndexedStorage<{
41+
[CLIENT_REGISTRATION_STORAGE_TYPE]: typeof CLIENT_REGISTRATION_STORAGE_DESCRIPTION,
42+
[PAT_STORAGE_TYPE]: typeof PAT_STORAGE_DESCRIPTION,
43+
}>;
44+
45+
constructor(
46+
storage: IndexedStorage<Record<string, never>>,
47+
protected readonly keyGen: JwkGenerator,
48+
protected readonly baseUrl: string,
49+
tokenExpiration: string = '30m',
50+
) {
51+
super();
52+
this.tokenExpiration = Math.floor(ms(tokenExpiration as StringValue) / 1000);
53+
this.storage = storage;
54+
this.initializeStorage();
55+
}
56+
57+
protected async initializeStorage(): Promise<void> {
58+
await this.storage.defineType(PAT_STORAGE_TYPE, PAT_STORAGE_DESCRIPTION);
59+
await this.storage.createIndex(PAT_STORAGE_TYPE, 'refreshToken');
60+
await this.storage.createIndex(PAT_STORAGE_TYPE, 'pat');
61+
await this.storage.createIndex(PAT_STORAGE_TYPE, 'registration');
62+
}
63+
64+
public async handle(input: HttpHandlerContext): Promise<HttpHandlerResponse<DialogOutput>> {
65+
const params = input.request.body;
66+
67+
try {
68+
reType(params, DialogInput);
69+
} catch (e) {
70+
throw new BadRequestHttpError(`Invalid token request body: ${createErrorMessage(e)}`);
71+
}
72+
73+
if (params.scope !== UMA_PROTECTION_SCOPE) {
74+
throw new BadRequestHttpError(`Expected scope '${UMA_PROTECTION_SCOPE}'`);
75+
}
76+
77+
const authorization = input.request.headers.authorization;
78+
79+
switch (params.grant_type) {
80+
case GRANT_TYPE_CLIENT_CREDENTIALS: return this.handleClientCredentials(authorization);
81+
case GRANT_TYPE_REFRESH_TOKEN: return this.handleRefreshToken(params.refresh_token, authorization);
82+
default: throw new BadRequestHttpError(`Unsupported grant_type ${params.grant_type}`);
83+
}
84+
}
85+
86+
protected async handleClientCredentials(authorization?: string): Promise<HttpHandlerResponse<DialogOutput>> {
87+
const registration = await this.findRegistration(authorization);
88+
const matches = await this.storage.findIds(PAT_STORAGE_TYPE, { registration: registration.id });
89+
return {
90+
status: 201,
91+
body: await this.generateToken(registration, matches.length > 0 ? matches[0] : undefined),
92+
};
93+
}
94+
95+
protected async handleRefreshToken(refreshToken?: string, authorization?: string):
96+
Promise<HttpHandlerResponse<DialogOutput>> {
97+
if (!refreshToken) {
98+
throw new BadRequestHttpError(`Missing refresh_token parameter`);
99+
}
100+
101+
const pats = await this.storage.find(PAT_STORAGE_TYPE, { refreshToken });
102+
if (pats.length === 0) {
103+
throw new ForbiddenHttpError(`Unknown refresh token ${refreshToken}`);
104+
}
105+
106+
const registration = await this.findRegistration(authorization);
107+
if (registration.id !== pats[0].registration) {
108+
throw new ForbiddenHttpError(`Wrong credentials for refresh token ${refreshToken}`);
109+
}
110+
111+
return {
112+
status: 201,
113+
body: await this.generateToken(registration, pats[0].id),
114+
};
115+
}
116+
117+
protected async findRegistration(authorization?: string): Promise<Registration> {
118+
if (typeof authorization !== 'string') {
119+
throw new UnauthorizedHttpError();
120+
}
121+
if (!matchesAuthorizationScheme('Basic', authorization)) {
122+
throw new BadRequestHttpError(`Expected scheme 'Basic'`);
123+
}
124+
125+
const decoded = Buffer.from(authorization.split(' ')[1], 'base64').toString('utf8');
126+
const [ id, secret ] = decoded.split(':');
127+
const match = await this.storage.find(
128+
CLIENT_REGISTRATION_STORAGE_TYPE,
129+
{ clientId: decodeURIComponent(id), clientSecret: decodeURIComponent(secret ?? '') }
130+
);
131+
if (match.length === 0) {
132+
throw new ForbiddenHttpError();
133+
}
134+
return match[0];
135+
}
136+
137+
protected async generateToken(registration: Registration, id?: string): Promise<DialogOutput> {
138+
const refresh_token = randomUUID();
139+
const expiration = Date.now() + this.tokenExpiration * 1000;
140+
const key = await this.keyGen.getPrivateKey();
141+
const jwk = await importJWK(key, key.alg);
142+
const pat = await new SignJWT({
143+
scope: UMA_PROTECTION_SCOPE,
144+
azp: registration.clientId,
145+
client_id: registration.clientId
146+
}).setProtectedHeader({ alg: key.alg, kid: key.kid })
147+
.setIssuedAt()
148+
.setSubject(registration.userId)
149+
.setIssuer(this.baseUrl)
150+
.setAudience(this.baseUrl)
151+
.setExpirationTime(Math.floor(expiration / 1000))
152+
.setJti(randomUUID())
153+
.sign(jwk);
154+
155+
const body = { pat, refreshToken: refresh_token, expiration, registration: registration.id };
156+
if (id) {
157+
await this.storage.set(PAT_STORAGE_TYPE, { id, ...body });
158+
} else {
159+
await this.storage.create(PAT_STORAGE_TYPE, body);
160+
}
161+
162+
return {
163+
access_token: pat,
164+
refresh_token,
165+
token_type: 'Bearer',
166+
expires_in: this.tokenExpiration,
167+
};
168+
}
169+
}

packages/uma/src/util/http/validate/PatRequestValidator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
CLIENT_REGISTRATION_STORAGE_DESCRIPTION,
99
CLIENT_REGISTRATION_STORAGE_TYPE
1010
} from '../../../routes/ClientRegistration';
11-
import { PAT_STORAGE_DESCRIPTION, PAT_STORAGE_TYPE } from '../../../routes/Token';
11+
import { PAT_STORAGE_DESCRIPTION, PAT_STORAGE_TYPE } from '../../../routes/token/UmaProtection';
1212
import { RequestValidator, RequestValidatorInput, RequestValidatorOutput } from './RequestValidator';
1313

1414
/**

0 commit comments

Comments
 (0)