Skip to content

Commit a5df8b9

Browse files
committed
feat: Support refresh tokens
1 parent 6db8bd2 commit a5df8b9

18 files changed

Lines changed: 480 additions & 26 deletions

File tree

packages/uma/config/credentials/verifiers/default.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@
3333
"TypedVerifier:_verifiers_key": "urn:ietf:params:oauth:token-type:access_token",
3434
"TypedVerifier:_verifiers_value": { "@id": "urn:uma:default:OidcVerifier" }
3535
},
36+
{
37+
"TypedVerifier:_verifiers_key": "urn:ietf:params:oauth:token-type:refresh_token",
38+
"TypedVerifier:_verifiers_value": {
39+
"@id": "urn:uma:default:RefreshTokenVerifier",
40+
"@type": "RefreshTokenVerifier",
41+
"refreshStore": { "@id": "urn:uma:default:RefreshTokenStore" }
42+
}
43+
},
3644
{
3745
"TypedVerifier:_verifiers_key": "urn:solidlab:uma:claims:formats:jwt",
3846
"TypedVerifier:_verifiers_value": {

packages/uma/config/dialog/negotiators/default.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,17 @@
99
"ticketStore": { "@id": "urn:uma:default:TicketStore" },
1010
"registrationStore": { "@id": "urn:uma:default:ResourceRegistrationStore" },
1111
"negotiator": {
12+
"@id": "urn:uma:default:BaseNegotiator",
1213
"@type": "BaseNegotiator",
1314
"verifier": { "@id": "urn:uma:default:Verifier" },
1415
"ticketStore": { "@id": "urn:uma:default:TicketStore" },
1516
"ticketingStrategy": { "@id": "urn:uma:default:TicketingStrategy" },
16-
"tokenFactory": { "@id": "urn:uma:default:TokenFactory" }
17+
"tokenFactory": { "@id": "urn:uma:default:TokenFactory" },
18+
"refreshTokenIssuer": {
19+
"@id": "urn:uma:default:RefreshTokenIssuer",
20+
"@type": "StoredRefreshTokenIssuer",
21+
"refreshStore": { "@id": "urn:uma:default:RefreshTokenStore" }
22+
}
1723
}
1824
}
1925
]

packages/uma/config/routes/tokens.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
"storage": { "@id": "urn:solid-server:default:ClientRegistrationStorage" },
1717
"keyGen": { "@id": "urn:uma:default:JwkGenerator" },
1818
"baseUrl": { "@id": "urn:uma:variables:baseUrl" }
19+
},
20+
"refreshTokenHandler": {
21+
"@id": "urn:uma:default:RefreshTokenHandler",
22+
"@type": "RefreshTokenHandler",
23+
"refreshStore": { "@id": "urn:uma:default:RefreshTokenStore" },
24+
"negotiator": { "@id": "urn:uma:default:Negotiator" }
1925
}
2026
},
2127
"path": "/uma/token"

packages/uma/config/tokens/storage/default.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
{
77
"@id": "urn:uma:default:TokenStore",
88
"@type": "MemoryMapStorage"
9+
},
10+
{
11+
"@id": "urn:uma:default:RefreshTokenStore",
12+
"@type": "MemoryMapStorage"
913
}
1014
]
1115
}

packages/uma/src/credentials/Formats.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export const JWT = 'urn:solidlab:uma:claims:formats:jwt';
33
export const UNSECURE = 'urn:solidlab:uma:claims:formats:webid';
44
export const OIDC = 'http://openid.net/specs/openid-connect-core-1_0.html#IDToken';
55
export const ACCESS_TOKEN = 'urn:ietf:params:oauth:token-type:access_token';
6+
export const REFRESH_TOKEN = 'urn:ietf:params:oauth:token-type:refresh_token';
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { BadRequestHttpError, ForbiddenHttpError, KeyValueStorage } from '@solid/community-server';
2+
import { getLoggerFor } from 'global-logger-factory';
3+
import { Permission } from '../../views/Permission';
4+
import { ClaimSet } from '../ClaimSet';
5+
import { Credential } from '../Credential';
6+
import { REFRESH_TOKEN } from '../Formats';
7+
import { Verifier } from './Verifier';
8+
9+
export type RefreshInformation = {
10+
claims: ClaimSet,
11+
permissions: Permission[],
12+
expiration: number,
13+
}
14+
15+
/**
16+
* Extracts claims from a refresh token based on those stored in a key/value storage.
17+
*/
18+
export class RefreshTokenVerifier implements Verifier {
19+
protected readonly logger = getLoggerFor(this);
20+
21+
public constructor(
22+
protected readonly refreshStore: KeyValueStorage<string, RefreshInformation>,
23+
) {
24+
}
25+
26+
public async verify(credential: Credential): Promise<ClaimSet> {
27+
this.logger.debug(`Verifying credential ${JSON.stringify(credential)}`);
28+
if (credential.format !== REFRESH_TOKEN) {
29+
throw new BadRequestHttpError(`Token format ${credential.format} does not match this processor's format.`);
30+
}
31+
const information = await this.refreshStore.get(credential.token);
32+
if (!information) {
33+
throw new BadRequestHttpError(`Unknown refresh token ${credential.token}.`);
34+
}
35+
if (Date.now() > information.expiration) {
36+
await this.refreshStore.delete(credential.token);
37+
throw new ForbiddenHttpError(`Expired refresh token ${credential.token}`);
38+
}
39+
40+
return information.claims;
41+
}
42+
}

packages/uma/src/dialog/BaseNegotiator.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { getOperationLogger } from '../logging/OperationLogger';
88
import { serializePolicyInstantiation } from '../logging/OperationSerializer';
99
import { TicketingStrategy } from '../ticketing/strategy/TicketingStrategy';
1010
import { Ticket } from '../ticketing/Ticket';
11+
import { RefreshTokenIssuer } from '../tokens/RefreshTokenIssuer';
1112
import { TokenFactory } from '../tokens/TokenFactory';
1213
import { reType } from '../util/ReType';
1314
import { Permission } from '../views/Permission';
@@ -29,12 +30,14 @@ export class BaseNegotiator implements Negotiator {
2930
* @param ticketStore - A KeyValueStorage to track Tickets.
3031
* @param ticketingStrategy - The strategy describing the life cycle of a Ticket.
3132
* @param tokenFactory - A factory for minting Access Tokens.
33+
* @param refreshTokenIssuer - An optional issuer for refresh tokens.
3234
*/
3335
public constructor(
34-
protected verifier: Verifier,
35-
protected ticketStore: KeyValueStorage<string, Ticket>,
36-
protected ticketingStrategy: TicketingStrategy,
37-
protected tokenFactory: TokenFactory,
36+
protected readonly verifier: Verifier,
37+
protected readonly ticketStore: KeyValueStorage<string, Ticket>,
38+
protected readonly ticketingStrategy: TicketingStrategy,
39+
protected readonly tokenFactory: TokenFactory,
40+
protected readonly refreshTokenIssuer?: RefreshTokenIssuer,
3841
) {}
3942

4043
/**
@@ -67,6 +70,9 @@ export class BaseNegotiator implements Negotiator {
6770
});
6871
this.logger.debug(`Minted token ${JSON.stringify(token)}`);
6972

73+
const refreshToken = this.refreshTokenIssuer ?
74+
await this.refreshTokenIssuer.issue({ ...updatedTicket.provided }, resolved.value) : undefined;
75+
7076
// TODO:: test logging
7177
this.operationLogger.addLogEntry(serializePolicyInstantiation())
7278

@@ -75,6 +81,7 @@ export class BaseNegotiator implements Negotiator {
7581
return ({
7682
access_token: token,
7783
token_type: tokenType,
84+
refresh_token: refreshToken,
7885
...(partial ? { partial: true } : {}),
7986
});
8087
}

packages/uma/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export * from './credentials/verify/UnsecureVerifier';
1515
export * from './credentials/verify/OidcVerifier';
1616
export * from './credentials/verify/JwtVerifier';
1717
export * from './credentials/verify/IriVerifier';
18+
export * from './credentials/verify/RefreshTokenVerifier';
1819

1920
// Dialog
2021
export * from './dialog/AggregatorNegotiator';
@@ -55,6 +56,7 @@ export * from './routes/BaseHandler';
5556
export * from './routes/ClientRegistration';
5657
export * from './routes/Collection';
5758
export * from './routes/token/UmaProtection';
59+
export * from './routes/token/RefreshTokenHandler';
5860

5961
// Tickets
6062
export * from './ticketing/Ticket';
@@ -66,6 +68,8 @@ export * from './ticketing/strategy/ImmediateAuthorizerStrategy';
6668
export * from './tokens/AccessToken';
6769
export * from './tokens/JwtTokenFactory';
6870
export * from './tokens/OpaqueTokenFactory';
71+
export * from './tokens/RefreshTokenIssuer';
72+
export * from './tokens/StoredRefreshTokenIssuer';
6973
export * from './tokens/TokenFactory';
7074

7175
// Views

packages/uma/src/routes/Token.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export class TokenRequestHandler extends HttpHandler {
1818
constructor(
1919
protected negotiator: Negotiator,
2020
protected readonly umaProtection: HttpHandler,
21+
protected readonly refreshTokenHandler: HttpHandler,
2122
) {
2223
super();
2324
}
@@ -37,6 +38,7 @@ export class TokenRequestHandler extends HttpHandler {
3738
}
3839

3940
switch (params.grant_type) {
41+
case 'refresh_token': return this.refreshTokenHandler.handleSafe(input);
4042
case GRANT_TYPE_UMA_TICKET: return this.handleUmaGrant(params);
4143
default: throw new BadRequestHttpError(`Unsupported grant_type ${params.grant_type}`);
4244
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import {
2+
BadRequestHttpError,
3+
ForbiddenHttpError,
4+
KeyValueStorage,
5+
} from '@solid/community-server';
6+
import { REFRESH_TOKEN } from '../../credentials/Formats';
7+
import { RefreshInformation } from '../../credentials/verify/RefreshTokenVerifier';
8+
import { Negotiator } from '../../dialog/Negotiator';
9+
import { DialogInput } from '../../dialog/Input';
10+
import { HttpHandler, HttpHandlerContext, HttpHandlerResponse } from '../../util/http/models/HttpHandler';
11+
import { reType } from '../../util/ReType';
12+
13+
/**
14+
* Handles refresh token requests.
15+
*/
16+
export class RefreshTokenHandler extends HttpHandler {
17+
public constructor(
18+
protected readonly refreshStore: KeyValueStorage<string, RefreshInformation>,
19+
protected readonly negotiator: Negotiator,
20+
) {
21+
super();
22+
}
23+
24+
public async handle(input: HttpHandlerContext): Promise<HttpHandlerResponse<any>> {
25+
const params = input.request.body;
26+
27+
try {
28+
reType(params, DialogInput);
29+
} catch (e) {
30+
throw new BadRequestHttpError(`Invalid token request body: ${e instanceof Error ? e.message : ''}`);
31+
}
32+
33+
if (params.grant_type !== 'refresh_token') {
34+
throw new BadRequestHttpError(`Unsupported grant_type ${params.grant_type}`);
35+
}
36+
37+
if (!params.refresh_token) {
38+
throw new BadRequestHttpError(`Missing refresh_token parameter`);
39+
}
40+
41+
const information = await this.refreshStore.get(params.refresh_token);
42+
if (!information) {
43+
throw new BadRequestHttpError(`Refresh token ${params.refresh_token} not recognized.`);
44+
}
45+
if (Date.now() > information.expiration) {
46+
await this.refreshStore.delete(params.refresh_token);
47+
throw new ForbiddenHttpError(`Expired refresh token ${params.refresh_token}`);
48+
}
49+
50+
const tokenResponse = await this.negotiator.negotiate({
51+
permissions: information.permissions,
52+
claim_token: params.refresh_token,
53+
claim_token_format: REFRESH_TOKEN,
54+
});
55+
56+
await this.refreshStore.delete(params.refresh_token);
57+
58+
return {
59+
status: 200,
60+
body: tokenResponse,
61+
};
62+
}
63+
}

0 commit comments

Comments
 (0)