diff --git a/packages/uma/config/credentials/verifiers/default.json b/packages/uma/config/credentials/verifiers/default.json index 66a3c16b..bbbd9974 100644 --- a/packages/uma/config/credentials/verifiers/default.json +++ b/packages/uma/config/credentials/verifiers/default.json @@ -33,6 +33,14 @@ "TypedVerifier:_verifiers_key": "urn:ietf:params:oauth:token-type:access_token", "TypedVerifier:_verifiers_value": { "@id": "urn:uma:default:OidcVerifier" } }, + { + "TypedVerifier:_verifiers_key": "urn:ietf:params:oauth:token-type:refresh_token", + "TypedVerifier:_verifiers_value": { + "@id": "urn:uma:default:RefreshTokenVerifier", + "@type": "RefreshTokenVerifier", + "refreshStore": { "@id": "urn:uma:default:RefreshTokenStore" } + } + }, { "TypedVerifier:_verifiers_key": "urn:solidlab:uma:claims:formats:jwt", "TypedVerifier:_verifiers_value": { diff --git a/packages/uma/config/dialog/negotiators/default.json b/packages/uma/config/dialog/negotiators/default.json index 0f6066b1..f339fab1 100644 --- a/packages/uma/config/dialog/negotiators/default.json +++ b/packages/uma/config/dialog/negotiators/default.json @@ -9,11 +9,17 @@ "ticketStore": { "@id": "urn:uma:default:TicketStore" }, "registrationStore": { "@id": "urn:uma:default:ResourceRegistrationStore" }, "negotiator": { + "@id": "urn:uma:default:BaseNegotiator", "@type": "BaseNegotiator", "verifier": { "@id": "urn:uma:default:Verifier" }, "ticketStore": { "@id": "urn:uma:default:TicketStore" }, "ticketingStrategy": { "@id": "urn:uma:default:TicketingStrategy" }, - "tokenFactory": { "@id": "urn:uma:default:TokenFactory" } + "tokenFactory": { "@id": "urn:uma:default:TokenFactory" }, + "refreshTokenIssuer": { + "@id": "urn:uma:default:RefreshTokenIssuer", + "@type": "StoredRefreshTokenIssuer", + "refreshStore": { "@id": "urn:uma:default:RefreshTokenStore" } + } } } ] diff --git a/packages/uma/config/routes/tokens.json b/packages/uma/config/routes/tokens.json index 0d33d1ac..b448d02e 100644 --- a/packages/uma/config/routes/tokens.json +++ b/packages/uma/config/routes/tokens.json @@ -10,9 +10,19 @@ "handler": { "@type": "TokenRequestHandler", "negotiator": { "@id": "urn:uma:default:Negotiator" }, - "storage": { "@id": "urn:solid-server:default:ClientRegistrationStorage" }, - "keyGen": { "@id": "urn:uma:default:JwkGenerator" }, - "baseUrl": { "@id": "urn:uma:variables:baseUrl" } + "umaProtection": { + "@id": "urn:uma:default:UmaProtection", + "@type": "UmaProtection", + "storage": { "@id": "urn:solid-server:default:ClientRegistrationStorage" }, + "keyGen": { "@id": "urn:uma:default:JwkGenerator" }, + "baseUrl": { "@id": "urn:uma:variables:baseUrl" } + }, + "refreshTokenHandler": { + "@id": "urn:uma:default:RefreshTokenHandler", + "@type": "RefreshTokenHandler", + "refreshStore": { "@id": "urn:uma:default:RefreshTokenStore" }, + "negotiator": { "@id": "urn:uma:default:Negotiator" } + } }, "path": "/uma/token" } diff --git a/packages/uma/config/tokens/storage/default.json b/packages/uma/config/tokens/storage/default.json index c08cfda1..c167f7af 100644 --- a/packages/uma/config/tokens/storage/default.json +++ b/packages/uma/config/tokens/storage/default.json @@ -6,6 +6,10 @@ { "@id": "urn:uma:default:TokenStore", "@type": "MemoryMapStorage" + }, + { + "@id": "urn:uma:default:RefreshTokenStore", + "@type": "MemoryMapStorage" } ] } diff --git a/packages/uma/src/credentials/Formats.ts b/packages/uma/src/credentials/Formats.ts index c2476791..36fc4623 100644 --- a/packages/uma/src/credentials/Formats.ts +++ b/packages/uma/src/credentials/Formats.ts @@ -3,3 +3,4 @@ export const JWT = 'urn:solidlab:uma:claims:formats:jwt'; export const UNSECURE = 'urn:solidlab:uma:claims:formats:webid'; export const OIDC = 'http://openid.net/specs/openid-connect-core-1_0.html#IDToken'; export const ACCESS_TOKEN = 'urn:ietf:params:oauth:token-type:access_token'; +export const REFRESH_TOKEN = 'urn:ietf:params:oauth:token-type:refresh_token'; diff --git a/packages/uma/src/credentials/verify/RefreshTokenVerifier.ts b/packages/uma/src/credentials/verify/RefreshTokenVerifier.ts new file mode 100644 index 00000000..0cd1d8d8 --- /dev/null +++ b/packages/uma/src/credentials/verify/RefreshTokenVerifier.ts @@ -0,0 +1,42 @@ +import { BadRequestHttpError, ForbiddenHttpError, KeyValueStorage } from '@solid/community-server'; +import { getLoggerFor } from 'global-logger-factory'; +import { Permission } from '../../views/Permission'; +import { ClaimSet } from '../ClaimSet'; +import { Credential } from '../Credential'; +import { REFRESH_TOKEN } from '../Formats'; +import { Verifier } from './Verifier'; + +export type RefreshInformation = { + claims: ClaimSet, + permissions: Permission[], + expiration: number, +} + +/** + * Extracts claims from a refresh token based on those stored in a key/value storage. + */ +export class RefreshTokenVerifier implements Verifier { + protected readonly logger = getLoggerFor(this); + + public constructor( + protected readonly refreshStore: KeyValueStorage, + ) { + } + + public async verify(credential: Credential): Promise { + this.logger.debug(`Verifying credential ${JSON.stringify(credential)}`); + if (credential.format !== REFRESH_TOKEN) { + throw new BadRequestHttpError(`Token format ${credential.format} does not match this processor's format.`); + } + const information = await this.refreshStore.get(credential.token); + if (!information) { + throw new BadRequestHttpError(`Unknown refresh token ${credential.token}.`); + } + if (Date.now() > information.expiration) { + await this.refreshStore.delete(credential.token); + throw new ForbiddenHttpError(`Expired refresh token ${credential.token}`); + } + + return information.claims; + } +} diff --git a/packages/uma/src/dialog/BaseNegotiator.ts b/packages/uma/src/dialog/BaseNegotiator.ts index f61f5304..16b6d279 100644 --- a/packages/uma/src/dialog/BaseNegotiator.ts +++ b/packages/uma/src/dialog/BaseNegotiator.ts @@ -7,6 +7,7 @@ import { getOperationLogger } from '../logging/OperationLogger'; import { serializePolicyInstantiation } from '../logging/OperationSerializer'; import { TicketingStrategy } from '../ticketing/strategy/TicketingStrategy'; import { Ticket } from '../ticketing/Ticket'; +import { RefreshTokenIssuer } from '../tokens/RefreshTokenIssuer'; import { TokenFactory } from '../tokens/TokenFactory'; import { reType } from '../util/ReType'; import { DialogInput } from './Input'; @@ -27,12 +28,14 @@ export class BaseNegotiator implements Negotiator { * @param ticketStore - A KeyValueStorage to track Tickets. * @param ticketingStrategy - The strategy describing the life cycle of a Ticket. * @param tokenFactory - A factory for minting Access Tokens. + * @param refreshTokenIssuer - An optional issuer for refresh tokens. */ public constructor( - protected verifier: Verifier, - protected ticketStore: KeyValueStorage, - protected ticketingStrategy: TicketingStrategy, - protected tokenFactory: TokenFactory, + protected readonly verifier: Verifier, + protected readonly ticketStore: KeyValueStorage, + protected readonly ticketingStrategy: TicketingStrategy, + protected readonly tokenFactory: TokenFactory, + protected readonly refreshTokenIssuer?: RefreshTokenIssuer, ) {} /** @@ -60,6 +63,9 @@ export class BaseNegotiator implements Negotiator { const { token, tokenType } = await this.tokenFactory.serialize({ permissions: resolved.value }); this.logger.debug(`Minted token ${JSON.stringify(token)}`); + const refreshToken = this.refreshTokenIssuer ? + await this.refreshTokenIssuer.issue({ ...updatedTicket.provided }, resolved.value) : undefined; + // TODO:: test logging this.operationLogger.addLogEntry(serializePolicyInstantiation()) @@ -68,6 +74,7 @@ export class BaseNegotiator implements Negotiator { return ({ access_token: token, token_type: tokenType, + refresh_token: refreshToken, }); } diff --git a/packages/uma/src/index.ts b/packages/uma/src/index.ts index 0f29eb84..2ab1532f 100644 --- a/packages/uma/src/index.ts +++ b/packages/uma/src/index.ts @@ -15,6 +15,7 @@ export * from './credentials/verify/UnsecureVerifier'; export * from './credentials/verify/OidcVerifier'; export * from './credentials/verify/JwtVerifier'; export * from './credentials/verify/IriVerifier'; +export * from './credentials/verify/RefreshTokenVerifier'; // Dialog export * from './dialog/AggregatorNegotiator'; @@ -54,6 +55,8 @@ export * from './routes/Contract'; export * from './routes/BaseHandler'; export * from './routes/ClientRegistration'; export * from './routes/Collection'; +export * from './routes/token/UmaProtection'; +export * from './routes/token/RefreshTokenHandler'; // Tickets export * from './ticketing/Ticket'; @@ -65,6 +68,8 @@ export * from './ticketing/strategy/ImmediateAuthorizerStrategy'; export * from './tokens/AccessToken'; export * from './tokens/JwtTokenFactory'; export * from './tokens/OpaqueTokenFactory'; +export * from './tokens/RefreshTokenIssuer'; +export * from './tokens/StoredRefreshTokenIssuer'; export * from './tokens/TokenFactory'; // Views diff --git a/packages/uma/src/routes/Token.ts b/packages/uma/src/routes/Token.ts index 6f8407fb..5090cded 100644 --- a/packages/uma/src/routes/Token.ts +++ b/packages/uma/src/routes/Token.ts @@ -1,64 +1,26 @@ -import { - BadRequestHttpError, - ForbiddenHttpError, - IndexedStorage, - JwkGenerator, - matchesAuthorizationScheme, - TypeObject, - UnauthorizedHttpError -} from '@solid/community-server'; +import { BadRequestHttpError, } from '@solid/community-server'; import { getLoggerFor } from 'global-logger-factory'; -import { importJWK, SignJWT } from 'jose'; -import ms, { StringValue } from 'ms'; -import { randomUUID } from 'node:crypto'; import { DialogInput } from '../dialog/Input'; import { Negotiator } from '../dialog/Negotiator'; import { NeedInfoError } from '../errors/NeedInfoError'; import { HttpHandler, HttpHandlerContext, HttpHandlerResponse } from '../util/http/models/HttpHandler'; import { reType } from '../util/ReType'; -import { CLIENT_REGISTRATION_STORAGE_DESCRIPTION, CLIENT_REGISTRATION_STORAGE_TYPE } from './ClientRegistration'; +import { UMA_PROTECTION_SCOPE } from './token/UmaProtection'; -export const GRANT_TYPE_CLIENT_CREDENTIALS = 'client_credentials'; -export const GRANT_TYPE_REFRESH_TOKEN = 'refresh_token'; export const GRANT_TYPE_UMA_TICKET = 'urn:ietf:params:oauth:grant-type:uma-ticket'; -export const PAT_STORAGE_TYPE = 'pat'; -export const PAT_STORAGE_DESCRIPTION = { - pat: 'string', - expiration: 'number', - refreshToken: 'string', - registration: `id:${CLIENT_REGISTRATION_STORAGE_TYPE}`, -} as const; - /** * The TokenRequestHandler implements the interface of the UMA Token Endpoint. */ export class TokenRequestHandler extends HttpHandler { protected readonly logger = getLoggerFor(this); - protected readonly tokenExpiration: number; - private readonly storage: IndexedStorage<{ - [CLIENT_REGISTRATION_STORAGE_TYPE]: typeof CLIENT_REGISTRATION_STORAGE_DESCRIPTION, - [PAT_STORAGE_TYPE]: typeof PAT_STORAGE_DESCRIPTION, - }>; constructor( protected negotiator: Negotiator, - storage: IndexedStorage>, - protected readonly keyGen: JwkGenerator, - protected readonly baseUrl: string, - tokenExpiration: string = '30m', + protected readonly umaProtection: HttpHandler, + protected readonly refreshTokenHandler: HttpHandler, ) { super(); - this.tokenExpiration = Math.floor(ms(tokenExpiration as StringValue)/1000); - this.storage = storage; - this.initializeStorage(); - } - - protected async initializeStorage(): Promise { - await this.storage.defineType(PAT_STORAGE_TYPE, PAT_STORAGE_DESCRIPTION); - await this.storage.createIndex(PAT_STORAGE_TYPE, 'refreshToken'); - await this.storage.createIndex(PAT_STORAGE_TYPE, 'pat'); - await this.storage.createIndex(PAT_STORAGE_TYPE, 'registration'); } public async handle(input: HttpHandlerContext): Promise> { @@ -71,9 +33,12 @@ export class TokenRequestHandler extends HttpHandler { throw new BadRequestHttpError(`Invalid token request body: ${e instanceof Error ? e.message : ''}`); } + if (params.scope === UMA_PROTECTION_SCOPE) { + return this.umaProtection.handleSafe(input); + } + switch (params.grant_type) { - case GRANT_TYPE_CLIENT_CREDENTIALS: return this.handlePatRequest(params, input.request.headers.authorization); - case GRANT_TYPE_REFRESH_TOKEN: return this.handleRefreshRequest(params, input.request.headers.authorization); + case 'refresh_token': return this.refreshTokenHandler.handleSafe(input); case GRANT_TYPE_UMA_TICKET: return this.handleUmaGrant(params); default: throw new BadRequestHttpError(`Unsupported grant_type ${params.grant_type}`); } @@ -98,88 +63,4 @@ export class TokenRequestHandler extends HttpHandler { throw e; // TODO: distinguish other errors } } - - protected async handlePatRequest(params: DialogInput, authorization?: string): Promise> { - const registration = await this.handlePreliminaryPatChecks(params, authorization); - // If there already is a stored token: reuse the ID - const matches = await this.storage.findIds(PAT_STORAGE_TYPE, { registration: registration.id }); - return this.generateToken(registration, matches.length > 0 ? matches[0] : undefined); - } - - protected async handleRefreshRequest(params: DialogInput, authorization?: string): Promise> { - if (!params.refresh_token) { - throw new BadRequestHttpError(`Missing refresh_token parameter`); - } - - const pats = await this.storage.find(PAT_STORAGE_TYPE, { refreshToken: params.refresh_token }); - if (pats.length === 0) { - throw new ForbiddenHttpError(`Unknown refresh token ${params.refresh_token}`); - } - const registration = await this.handlePreliminaryPatChecks(params, authorization); - if (registration.id !== pats[0].registration) { - throw new ForbiddenHttpError(`Wrong credentials for refresh token ${params.refresh_token}`); - } - - return this.generateToken(registration, pats[0].id); - } - - // Returns the UserId if there is a match, or throws an error - protected async handlePreliminaryPatChecks(params: DialogInput, authorization?: string): - Promise> { - if (typeof authorization !== 'string') { - throw new UnauthorizedHttpError(); - } - if (params.scope !== 'uma_protection') { - throw new BadRequestHttpError(`Expected scope 'uma_protection'`); - } - if (!matchesAuthorizationScheme('Basic', authorization)) { - throw new BadRequestHttpError(`Expected scheme 'Basic'`); - } - const decoded = Buffer.from(authorization.split(' ')[1], 'base64').toString('utf8'); - const [ id, secret ] = decoded.split(':'); - const match = await this.storage.find(CLIENT_REGISTRATION_STORAGE_TYPE, - { clientId: decodeURIComponent(id), clientSecret: decodeURIComponent(secret ?? '') }); - if (match.length === 0) { - throw new ForbiddenHttpError(); - } - return match[0]; - } - - protected async generateToken(registration: TypeObject, id?: string): - Promise> { - const refresh_token = randomUUID(); - const expiration = Date.now() + this.tokenExpiration * 1000; - const key = await this.keyGen.getPrivateKey(); - const jwk = await importJWK(key, key.alg); - const pat = await new SignJWT({ - scope: 'uma_protection', - azp: registration.clientId, - client_id: registration.clientId - }).setProtectedHeader({ alg: key.alg, kid: key.kid }) - .setIssuedAt() - .setSubject(registration.userId) - .setIssuer(this.baseUrl) - .setAudience(this.baseUrl) - .setExpirationTime(Math.floor(expiration/1000)) - .setJti(randomUUID()) - .sign(jwk); - - const body = { pat, refreshToken: refresh_token, expiration, registration: registration.id }; - if (id) { - await this.storage.set(PAT_STORAGE_TYPE, { id, ...body }); - } else { - await this.storage.create(PAT_STORAGE_TYPE, body); - } - - return { - status: 201, - body: { - access_token: pat, - refresh_token, - token_type: 'Bearer', - expires_in: this.tokenExpiration, - scope: 'uma_protection', - } - } - } } diff --git a/packages/uma/src/routes/token/RefreshTokenHandler.ts b/packages/uma/src/routes/token/RefreshTokenHandler.ts new file mode 100644 index 00000000..d3b8c2a1 --- /dev/null +++ b/packages/uma/src/routes/token/RefreshTokenHandler.ts @@ -0,0 +1,63 @@ +import { + BadRequestHttpError, + ForbiddenHttpError, + KeyValueStorage, +} from '@solid/community-server'; +import { REFRESH_TOKEN } from '../../credentials/Formats'; +import { RefreshInformation } from '../../credentials/verify/RefreshTokenVerifier'; +import { Negotiator } from '../../dialog/Negotiator'; +import { DialogInput } from '../../dialog/Input'; +import { HttpHandler, HttpHandlerContext, HttpHandlerResponse } from '../../util/http/models/HttpHandler'; +import { reType } from '../../util/ReType'; + +/** + * Handles refresh token requests. + */ +export class RefreshTokenHandler extends HttpHandler { + public constructor( + protected readonly refreshStore: KeyValueStorage, + protected readonly negotiator: Negotiator, + ) { + super(); + } + + public async handle(input: HttpHandlerContext): Promise> { + const params = input.request.body; + + try { + reType(params, DialogInput); + } catch (e) { + throw new BadRequestHttpError(`Invalid token request body: ${e instanceof Error ? e.message : ''}`); + } + + if (params.grant_type !== 'refresh_token') { + throw new BadRequestHttpError(`Unsupported grant_type ${params.grant_type}`); + } + + if (!params.refresh_token) { + throw new BadRequestHttpError(`Missing refresh_token parameter`); + } + + const information = await this.refreshStore.get(params.refresh_token); + if (!information) { + throw new BadRequestHttpError(`Refresh token ${params.refresh_token} not recognized.`); + } + if (Date.now() > information.expiration) { + await this.refreshStore.delete(params.refresh_token); + throw new ForbiddenHttpError(`Expired refresh token ${params.refresh_token}`); + } + + const tokenResponse = await this.negotiator.negotiate({ + permissions: information.permissions, + claim_token: params.refresh_token, + claim_token_format: REFRESH_TOKEN, + }); + + await this.refreshStore.delete(params.refresh_token); + + return { + status: 200, + body: tokenResponse, + }; + } +} diff --git a/packages/uma/src/routes/token/UmaProtection.ts b/packages/uma/src/routes/token/UmaProtection.ts new file mode 100644 index 00000000..666bb465 --- /dev/null +++ b/packages/uma/src/routes/token/UmaProtection.ts @@ -0,0 +1,169 @@ +import { + BadRequestHttpError, + createErrorMessage, + ForbiddenHttpError, + IndexedStorage, + JwkGenerator, + matchesAuthorizationScheme, + TypeObject, + UnauthorizedHttpError +} from '@solid/community-server'; +import { importJWK, SignJWT } from 'jose'; +import ms, { StringValue } from 'ms'; +import { randomUUID } from 'node:crypto'; +import { DialogInput } from '../../dialog/Input'; +import { DialogOutput } from '../../dialog/Output'; +import { HttpHandler, HttpHandlerContext, HttpHandlerResponse } from '../../util/http/models/HttpHandler'; +import { reType } from '../../util/ReType'; +import { CLIENT_REGISTRATION_STORAGE_DESCRIPTION, CLIENT_REGISTRATION_STORAGE_TYPE } from '../ClientRegistration'; + +const GRANT_TYPE_CLIENT_CREDENTIALS = 'client_credentials'; +const GRANT_TYPE_REFRESH_TOKEN = 'refresh_token'; + +export const UMA_PROTECTION_SCOPE = 'uma_protection'; + +export const PAT_STORAGE_TYPE = 'pat'; +export const PAT_STORAGE_DESCRIPTION = { + pat: 'string', + expiration: 'number', + refreshToken: 'string', + registration: `id:${CLIENT_REGISTRATION_STORAGE_TYPE}`, +} as const; + +type Registration = TypeObject; + +/** + * Handles the requests related to the UMA protection API, including generating and refreshing PATs. + */ +export class UmaProtection extends HttpHandler { + protected readonly tokenExpiration: number; + private readonly storage: IndexedStorage<{ + [CLIENT_REGISTRATION_STORAGE_TYPE]: typeof CLIENT_REGISTRATION_STORAGE_DESCRIPTION, + [PAT_STORAGE_TYPE]: typeof PAT_STORAGE_DESCRIPTION, + }>; + + constructor( + storage: IndexedStorage>, + protected readonly keyGen: JwkGenerator, + protected readonly baseUrl: string, + tokenExpiration: string = '30m', + ) { + super(); + this.tokenExpiration = Math.floor(ms(tokenExpiration as StringValue) / 1000); + this.storage = storage; + this.initializeStorage(); + } + + protected async initializeStorage(): Promise { + await this.storage.defineType(PAT_STORAGE_TYPE, PAT_STORAGE_DESCRIPTION); + await this.storage.createIndex(PAT_STORAGE_TYPE, 'refreshToken'); + await this.storage.createIndex(PAT_STORAGE_TYPE, 'pat'); + await this.storage.createIndex(PAT_STORAGE_TYPE, 'registration'); + } + + public async handle(input: HttpHandlerContext): Promise> { + const params = input.request.body; + + try { + reType(params, DialogInput); + } catch (e) { + throw new BadRequestHttpError(`Invalid token request body: ${createErrorMessage(e)}`); + } + + if (params.scope !== UMA_PROTECTION_SCOPE) { + throw new BadRequestHttpError(`Expected scope '${UMA_PROTECTION_SCOPE}'`); + } + + const authorization = input.request.headers.authorization; + + switch (params.grant_type) { + case GRANT_TYPE_CLIENT_CREDENTIALS: return this.handleClientCredentials(authorization); + case GRANT_TYPE_REFRESH_TOKEN: return this.handleRefreshToken(params.refresh_token, authorization); + default: throw new BadRequestHttpError(`Unsupported grant_type ${params.grant_type}`); + } + } + + protected async handleClientCredentials(authorization?: string): Promise> { + const registration = await this.findRegistration(authorization); + const matches = await this.storage.findIds(PAT_STORAGE_TYPE, { registration: registration.id }); + return { + status: 201, + body: await this.generateToken(registration, matches.length > 0 ? matches[0] : undefined), + }; + } + + protected async handleRefreshToken(refreshToken?: string, authorization?: string): + Promise> { + if (!refreshToken) { + throw new BadRequestHttpError(`Missing refresh_token parameter`); + } + + const pats = await this.storage.find(PAT_STORAGE_TYPE, { refreshToken }); + if (pats.length === 0) { + throw new ForbiddenHttpError(`Unknown refresh token ${refreshToken}`); + } + + const registration = await this.findRegistration(authorization); + if (registration.id !== pats[0].registration) { + throw new ForbiddenHttpError(`Wrong credentials for refresh token ${refreshToken}`); + } + + return { + status: 201, + body: await this.generateToken(registration, pats[0].id), + }; + } + + protected async findRegistration(authorization?: string): Promise { + if (typeof authorization !== 'string') { + throw new UnauthorizedHttpError(); + } + if (!matchesAuthorizationScheme('Basic', authorization)) { + throw new BadRequestHttpError(`Expected scheme 'Basic'`); + } + + const decoded = Buffer.from(authorization.split(' ')[1], 'base64').toString('utf8'); + const [ id, secret ] = decoded.split(':'); + const match = await this.storage.find( + CLIENT_REGISTRATION_STORAGE_TYPE, + { clientId: decodeURIComponent(id), clientSecret: decodeURIComponent(secret ?? '') } + ); + if (match.length === 0) { + throw new ForbiddenHttpError(); + } + return match[0]; + } + + protected async generateToken(registration: Registration, id?: string): Promise { + const refresh_token = randomUUID(); + const expiration = Date.now() + this.tokenExpiration * 1000; + const key = await this.keyGen.getPrivateKey(); + const jwk = await importJWK(key, key.alg); + const pat = await new SignJWT({ + scope: UMA_PROTECTION_SCOPE, + azp: registration.clientId, + client_id: registration.clientId + }).setProtectedHeader({ alg: key.alg, kid: key.kid }) + .setIssuedAt() + .setSubject(registration.userId) + .setIssuer(this.baseUrl) + .setAudience(this.baseUrl) + .setExpirationTime(Math.floor(expiration / 1000)) + .setJti(randomUUID()) + .sign(jwk); + + const body = { pat, refreshToken: refresh_token, expiration, registration: registration.id }; + if (id) { + await this.storage.set(PAT_STORAGE_TYPE, { id, ...body }); + } else { + await this.storage.create(PAT_STORAGE_TYPE, body); + } + + return { + access_token: pat, + refresh_token, + token_type: 'Bearer', + expires_in: this.tokenExpiration, + }; + } +} diff --git a/packages/uma/src/tokens/JwtTokenFactory.ts b/packages/uma/src/tokens/JwtTokenFactory.ts index 10c36bec..024a365c 100644 --- a/packages/uma/src/tokens/JwtTokenFactory.ts +++ b/packages/uma/src/tokens/JwtTokenFactory.ts @@ -54,6 +54,8 @@ export class JwtTokenFactory extends TokenFactory { .sign(jwk); this.logger.debug(`Issued new JWT Token ${JSON.stringify(token)}`); + // TODO: tokenstore should expire tokens eventually; can use expiring storage, + // or just normal store with timer-based cleanup. await this.tokenStore.set(jwt, token); return { token: jwt, tokenType: 'Bearer' }; } diff --git a/packages/uma/src/tokens/RefreshTokenIssuer.ts b/packages/uma/src/tokens/RefreshTokenIssuer.ts new file mode 100644 index 00000000..f75e9515 --- /dev/null +++ b/packages/uma/src/tokens/RefreshTokenIssuer.ts @@ -0,0 +1,6 @@ +import { ClaimSet } from '../credentials/ClaimSet'; +import { Permission } from '../views/Permission'; + +export interface RefreshTokenIssuer { + issue(claims: ClaimSet, permissions: Permission[]): Promise; +} diff --git a/packages/uma/src/tokens/StoredRefreshTokenIssuer.ts b/packages/uma/src/tokens/StoredRefreshTokenIssuer.ts new file mode 100644 index 00000000..13c394d2 --- /dev/null +++ b/packages/uma/src/tokens/StoredRefreshTokenIssuer.ts @@ -0,0 +1,34 @@ +import { KeyValueStorage } from '@solid/community-server'; +import ms, { StringValue } from 'ms'; +import { randomUUID } from 'node:crypto'; +import { ClaimSet } from '../credentials/ClaimSet'; +import { RefreshInformation } from '../credentials/verify/RefreshTokenVerifier'; +import { Permission } from '../views/Permission'; +import { RefreshTokenIssuer } from './RefreshTokenIssuer'; + +/** + * Generates refresh tokens and stores them in a storage. + * Default expiration time is 7 days. + */ +export class StoredRefreshTokenIssuer implements RefreshTokenIssuer { + protected readonly refreshExpiration: number; + + public constructor( + protected readonly refreshStore: KeyValueStorage, + refreshExpiration: string = '7d', + ) { + this.refreshExpiration = ms(refreshExpiration as StringValue); + } + + public async issue(claims: ClaimSet, permissions: Permission[]): Promise { + const refreshToken = randomUUID(); + // TODO: expired tokens should be removed; can use expiring storage, + // or just normal store with timer-based cleanup. + await this.refreshStore.set(refreshToken, { + claims, + permissions, + expiration: Date.now() + this.refreshExpiration, + }); + return refreshToken; + } +} diff --git a/packages/uma/src/util/http/validate/PatRequestValidator.ts b/packages/uma/src/util/http/validate/PatRequestValidator.ts index 58a8700a..a1fde1ce 100644 --- a/packages/uma/src/util/http/validate/PatRequestValidator.ts +++ b/packages/uma/src/util/http/validate/PatRequestValidator.ts @@ -8,7 +8,7 @@ import { CLIENT_REGISTRATION_STORAGE_DESCRIPTION, CLIENT_REGISTRATION_STORAGE_TYPE } from '../../../routes/ClientRegistration'; -import { PAT_STORAGE_DESCRIPTION, PAT_STORAGE_TYPE } from '../../../routes/Token'; +import { PAT_STORAGE_DESCRIPTION, PAT_STORAGE_TYPE } from '../../../routes/token/UmaProtection'; import { RequestValidator, RequestValidatorInput, RequestValidatorOutput } from './RequestValidator'; /** diff --git a/packages/uma/test/unit/dialog/BaseNegotiator.test.ts b/packages/uma/test/unit/dialog/BaseNegotiator.test.ts index e2ba3b18..e5e6177c 100644 --- a/packages/uma/test/unit/dialog/BaseNegotiator.test.ts +++ b/packages/uma/test/unit/dialog/BaseNegotiator.test.ts @@ -7,6 +7,7 @@ import { DialogInput } from '../../../src/dialog/Input'; import { NeedInfoError } from '../../../src/errors/NeedInfoError'; import { TicketingStrategy } from '../../../src/ticketing/strategy/TicketingStrategy'; import { Ticket } from '../../../src/ticketing/Ticket'; +import { RefreshTokenIssuer } from '../../../src/tokens/RefreshTokenIssuer'; import { SerializedToken, TokenFactory } from '../../../src/tokens/TokenFactory'; describe('BaseNegotiator', (): void => { @@ -28,6 +29,7 @@ describe('BaseNegotiator', (): void => { let ticketStore: Mocked>; let ticketingStrategy: Mocked; let tokenFactory: Mocked; + let refreshTokenIssuer: Mocked; let negotiator: BaseNegotiator; beforeEach(async(): Promise => { @@ -58,7 +60,11 @@ describe('BaseNegotiator', (): void => { deserialize: vi.fn(), }; - negotiator = new BaseNegotiator(verifier, ticketStore, ticketingStrategy, tokenFactory); + refreshTokenIssuer = { + issue: vi.fn().mockResolvedValue('refresh-token'), + }; + + negotiator = new BaseNegotiator(verifier, ticketStore, ticketingStrategy, tokenFactory, refreshTokenIssuer); }); it('errors if the input is in the wrong type.', async(): Promise => { @@ -66,7 +72,11 @@ describe('BaseNegotiator', (): void => { }); it('returns the token if everything was successful.', async(): Promise => { - await expect(negotiator.negotiate(input)).resolves.toEqual({ access_token: 'token', token_type: 'type' }); + await expect(negotiator.negotiate(input)).resolves.toEqual({ + access_token: 'token', + token_type: 'type', + refresh_token: 'refresh-token', + }); expect(ticketStore.get).toHaveBeenCalledTimes(0); expect(ticketStore.set).toHaveBeenCalledTimes(0); expect(ticketStore.delete).toHaveBeenCalledTimes(0); @@ -77,6 +87,26 @@ describe('BaseNegotiator', (): void => { expect(tokenFactory.serialize).toHaveBeenCalledTimes(1); expect(tokenFactory.serialize).toHaveBeenLastCalledWith( { permissions: { resource_id: 'id1', resource_scopes: [ 'scope1' ] } }); + expect(refreshTokenIssuer.issue).toHaveBeenCalledTimes(1); + expect(refreshTokenIssuer.issue).toHaveBeenLastCalledWith( + { claim: 'value' }, + { resource_id: 'id1', resource_scopes: [ 'scope1' ] }); + }); + + it('does not return refresh token if refresh token issuer is not configured.', async(): Promise => { + const noRefreshNegotiator = new BaseNegotiator( + verifier, + ticketStore, + ticketingStrategy, + tokenFactory, + ); + + await expect(noRefreshNegotiator.negotiate(input)).resolves.toEqual({ + access_token: 'token', + token_type: 'type', + }); + expect(tokenFactory.serialize).toHaveBeenCalledTimes(1); + expect(refreshTokenIssuer.issue).toHaveBeenCalledTimes(0); }); it('errors if there is no existing ticket and no permission request.', async(): Promise => { @@ -117,7 +147,7 @@ describe('BaseNegotiator', (): void => { it('uses the stored ticket if it is known.', async(): Promise => { ticketData.set('ticket', ticket); await expect(negotiator.negotiate({ ...input, ticket: 'ticket' })).resolves - .toEqual({ access_token: 'token', token_type: 'type' }); + .toEqual({ access_token: 'token', token_type: 'type', refresh_token: 'refresh-token' }); expect(ticketStore.get).toHaveBeenCalledTimes(1); expect(ticketStore.get).toHaveBeenLastCalledWith('ticket'); expect(ticketStore.set).toHaveBeenCalledTimes(0); @@ -129,6 +159,10 @@ describe('BaseNegotiator', (): void => { expect(tokenFactory.serialize).toHaveBeenCalledTimes(1); expect(tokenFactory.serialize).toHaveBeenLastCalledWith( { permissions: { resource_id: 'id1', resource_scopes: [ 'scope1' ] } }); + expect(refreshTokenIssuer.issue).toHaveBeenCalledTimes(1); + expect(refreshTokenIssuer.issue).toHaveBeenLastCalledWith( + { claim: 'value' }, + { resource_id: 'id1', resource_scopes: [ 'scope1' ] }); }); it('errors if invalid credentials are provided.', async(): Promise => { @@ -140,7 +174,7 @@ describe('BaseNegotiator', (): void => { it('processes the credentials if they are provided.', async(): Promise => { await expect(negotiator.negotiate({ ...input, claim_token: 'token', claim_token_format: 'format' })).resolves - .toEqual({ access_token: 'token', token_type: 'type' }); + .toEqual({ access_token: 'token', token_type: 'type', refresh_token: 'refresh-token' }); expect(ticketStore.get).toHaveBeenCalledTimes(0); expect(ticketStore.set).toHaveBeenCalledTimes(0); expect(ticketStore.delete).toHaveBeenCalledTimes(0); @@ -153,6 +187,10 @@ describe('BaseNegotiator', (): void => { expect(tokenFactory.serialize).toHaveBeenCalledTimes(1); expect(tokenFactory.serialize).toHaveBeenLastCalledWith( { permissions: { resource_id: 'id1', resource_scopes: [ 'scope1' ] } }); + expect(refreshTokenIssuer.issue).toHaveBeenCalledTimes(1); + expect(refreshTokenIssuer.issue).toHaveBeenLastCalledWith( + { claim: 'value' }, + { resource_id: 'id1', resource_scopes: [ 'scope1' ] }); }); it('supports multiple claim tokens.', async(): Promise => { @@ -162,7 +200,7 @@ describe('BaseNegotiator', (): void => { { claim_token: 'token2', claim_token_format: 'format2' }, ], })).resolves - .toEqual({ access_token: 'token', token_type: 'type' }); + .toEqual({ access_token: 'token', token_type: 'type', refresh_token: 'refresh-token' }); expect(ticketingStrategy.initializeTicket).toHaveBeenCalledTimes(1); expect(ticketingStrategy.initializeTicket).toHaveBeenLastCalledWith(input.permissions); expect(verifier.verify).toHaveBeenCalledTimes(2); diff --git a/packages/uma/test/unit/routes/RefreshTokenHandler.test.ts b/packages/uma/test/unit/routes/RefreshTokenHandler.test.ts new file mode 100644 index 00000000..91ff574e --- /dev/null +++ b/packages/uma/test/unit/routes/RefreshTokenHandler.test.ts @@ -0,0 +1,113 @@ +import { BadRequestHttpError, ForbiddenHttpError, KeyValueStorage } from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { REFRESH_TOKEN } from '../../../src/credentials/Formats'; +import { RefreshInformation } from '../../../src/credentials/verify/RefreshTokenVerifier'; +import { Negotiator } from '../../../src/dialog/Negotiator'; +import { RefreshTokenHandler } from '../../../src/routes/token/RefreshTokenHandler'; +import { HttpHandlerContext } from '../../../src/util/http/models/HttpHandler'; + +vi.useFakeTimers(); + +describe('RefreshTokenHandler', (): void => { + const refreshToken = 'refresh-token'; + const expiration = Date.now() + 5_000; + const claims = { webid: 'https://alice.example/#me' }; + const permissions = [ + { resource_id: 'https://pod.example/private/', resource_scopes: [ 'read' ] }, + ]; + + let context: HttpHandlerContext; + let refreshStore: Mocked>; + let negotiator: Mocked; + let handler: RefreshTokenHandler; + + beforeEach(async(): Promise => { + context = { + request: { + url: new URL('http://example.com/token'), + method: 'POST', + headers: {}, + body: { + grant_type: 'refresh_token', + refresh_token: refreshToken, + }, + }, + }; + + refreshStore = { + get: vi.fn().mockResolvedValue({ claims, permissions, expiration }), + set: vi.fn(), + delete: vi.fn(), + } satisfies Partial> as any; + + negotiator = { + negotiate: vi.fn().mockResolvedValue({ + access_token: 'new-access-token', + token_type: 'Bearer', + refresh_token: 'rotated-refresh-token', + }), + }; + + handler = new RefreshTokenHandler(refreshStore, negotiator); + }); + + it('errors if request body is invalid.', async(): Promise => { + context.request.body = { refresh_token: 1 }; + await expect(handler.handle(context)).rejects + .toThrow('Invalid token request body: value is neither of the union types'); + }); + + it('errors for unsupported grant type.', async(): Promise => { + context.request.body = { grant_type: 'client_credentials' }; + await expect(handler.handle(context)).rejects + .toThrow('Unsupported grant_type client_credentials'); + }); + + it('errors if refresh token is missing.', async(): Promise => { + context.request.body = { grant_type: 'refresh_token' }; + await expect(handler.handle(context)).rejects.toThrow('Missing refresh_token parameter'); + }); + + it('errors if refresh token is unknown.', async(): Promise => { + refreshStore.get.mockResolvedValueOnce(undefined); + await expect(handler.handle(context)).rejects + .toThrow(`Refresh token ${refreshToken} not recognized.`); + }); + + it('errors if refresh token is expired and deletes it.', async(): Promise => { + refreshStore.get.mockResolvedValueOnce({ claims, permissions, expiration: Date.now() - 100 }); + await expect(handler.handle(context)).rejects.toThrow(ForbiddenHttpError); + expect(refreshStore.delete).toHaveBeenCalledTimes(1); + expect(refreshStore.delete).toHaveBeenLastCalledWith(refreshToken); + }); + + it('re-negotiates and deletes old refresh token after rotation.', async(): Promise => { + const response = await handler.handle(context); + + expect(negotiator.negotiate).toHaveBeenCalledTimes(1); + expect(negotiator.negotiate).toHaveBeenLastCalledWith({ + permissions, + claim_token: refreshToken, + claim_token_format: REFRESH_TOKEN, + }); + + expect(refreshStore.set).toHaveBeenCalledTimes(0); + expect(refreshStore.delete).toHaveBeenCalledExactlyOnceWith(refreshToken); + + expect(response).toEqual({ + status: 200, + body: { + access_token: 'new-access-token', + token_type: 'Bearer', + refresh_token: 'rotated-refresh-token', + }, + }); + }); + + it('does not rotate token when re-negotiation fails.', async(): Promise => { + negotiator.negotiate.mockRejectedValueOnce(new BadRequestHttpError('authorization no longer valid')); + await expect(handler.handle(context)).rejects.toThrow('authorization no longer valid'); + expect(refreshStore.set).toHaveBeenCalledTimes(0); + expect(refreshStore.delete).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/uma/test/unit/routes/Token.test.ts b/packages/uma/test/unit/routes/Token.test.ts index 51d861be..b5fb36d7 100644 --- a/packages/uma/test/unit/routes/Token.test.ts +++ b/packages/uma/test/unit/routes/Token.test.ts @@ -1,52 +1,17 @@ -import { - AlgJwk, - ForbiddenHttpError, - IndexedStorage, - JwkGenerator, - UnauthorizedHttpError -} from '@solid/community-server'; -import { decodeJwt, exportJWK, generateKeyPair, GenerateKeyPairResult, importJWK, jwtVerify, KeyLike } from 'jose'; -import { beforeAll, Mocked } from 'vitest'; +import { Mocked } from 'vitest'; import { Negotiator } from '../../../src/dialog/Negotiator'; import { NeedInfoError } from '../../../src/errors/NeedInfoError'; -import { - CLIENT_REGISTRATION_STORAGE_DESCRIPTION, - CLIENT_REGISTRATION_STORAGE_TYPE -} from '../../../src/routes/ClientRegistration'; -import { PAT_STORAGE_DESCRIPTION, PAT_STORAGE_TYPE, TokenRequestHandler } from '../../../src/routes/Token'; -import { HttpHandlerRequest } from '../../../src/util/http/models/HttpHandler'; - -vi.useFakeTimers(); +import { TokenRequestHandler } from '../../../src/routes/Token'; +import { HttpHandler, HttpHandlerRequest } from '../../../src/util/http/models/HttpHandler'; describe('Token', (): void => { - const now = Date.now(); - const clientUri = 'http://example.org'; - const baseUrl = 'http://example.com'; - const userId = 'userId'; - const registrationId = 'registrationId'; - const clientId = 'clientId'; - const clientSecret = 'sec ret'; - const encoded = Buffer.from('clientId:sec%20ret', 'utf8').toString('base64'); - const alg = 'ES256'; - let keys: GenerateKeyPairResult; - let publicKey: AlgJwk; - let privateKey: AlgJwk; let request: HttpHandlerRequest; let negotiator: Mocked; - let storage: Mocked>; - let keyGen: Mocked; + let umaProtection: Mocked; + let refreshTokenHandler: Mocked; let handler: TokenRequestHandler; - beforeAll(async(): Promise => { - keys = await generateKeyPair(alg); - publicKey = { ...await exportJWK(keys.publicKey), alg }; - privateKey = { ...await exportJWK(keys.privateKey), alg }; - }); - beforeEach(async(): Promise => { request = { url: new URL('http://example.com/token'), @@ -60,22 +25,15 @@ describe('Token', (): void => { negotiate: vi.fn().mockResolvedValue('response'), }; - storage = { - defineType: vi.fn(), - createIndex: vi.fn(), - find: vi.fn().mockResolvedValue([{ id: registrationId, clientId, clientSecret, clientUri, userId }]), - findIds: vi.fn().mockResolvedValue([]), - set: vi.fn(), - create: vi.fn(), - } as any; + umaProtection = { + handleSafe: vi.fn().mockResolvedValue({ status: 201, body: { access_token: 'pat' }}), + } satisfies Partial as any; - keyGen = { - alg: alg, - getPublicKey: vi.fn().mockResolvedValue(publicKey), - getPrivateKey: vi.fn().mockResolvedValue(privateKey), - }; + refreshTokenHandler = { + handleSafe: vi.fn().mockResolvedValue({ status: 200, body: { access_token: 'rpt', refresh_token: 'next' }}), + } satisfies Partial as any; - handler = new TokenRequestHandler(negotiator, storage as any, keyGen, baseUrl); + handler = new TokenRequestHandler(negotiator, umaProtection, refreshTokenHandler); }); it('throws an error if the body is invalid.', async(): Promise => { @@ -105,11 +63,11 @@ describe('Token', (): void => { }); it('returns a 403 with the ticket if negotiation needs more info.', async(): Promise => { - const needInfo = new NeedInfoError('msg', 'ticket', { required_claims: { claim_token_format: [[ 'format' ]] } }); + const needInfo = new NeedInfoError('msg', 'ticket', { required_claims: [{ claim_token_format: 'format' }] }); negotiator.negotiate.mockRejectedValueOnce(needInfo); await expect(handler.handle({ request })).resolves.toEqual({ status: 403, body: { ticket: 'ticket', - required_claims: { claim_token_format: [[ 'format' ]] }, + required_claims: [{ claim_token_format: 'format' }], }}); }); @@ -119,203 +77,48 @@ describe('Token', (): void => { }); }); - describe('using client credentials', (): void => { + describe('delegating uma_protection requests', (): void => { beforeEach(async(): Promise => { request.headers = { - authorization: `Basic ${encoded}`, + authorization: 'Basic encoded', }; request.body = { - grant_type: 'client_credentials', + grant_type: 'not supported here', scope: 'uma_protection', }; }); - it('errors if the authorization header is missing.', async(): Promise => { - delete request.headers.authorization; - await expect(handler.handle({ request })).rejects.toThrow(UnauthorizedHttpError); - }); - - it('errors if the scope is wrong.', async(): Promise => { - request.body = { grant_type: 'client_credentials' }; - await expect(handler.handle({ request })).rejects.toThrow(`Expected scope 'uma_protection'`); - }); - - it('errors for non-Basic authorization schemes.', async(): Promise => { - request.headers.authorization = `Bearer ${encoded}`; - await expect(handler.handle({ request })).rejects.toThrow(`Expected scheme 'Basic'`); - }); - - it('errors if the credentials are not known.', async(): Promise => { - storage.find.mockResolvedValueOnce([]); - await expect(handler.handle({ request })).rejects.toThrow(ForbiddenHttpError); - }); - - it('generates a token response.', async(): Promise => { + it('returns the delegated response unchanged.', async(): Promise => { const response = await handler.handle({ request }); - expect(response).toEqual({ - status: 201, - body: { - access_token: expect.any(String), - refresh_token: expect.any(String), - token_type: 'Bearer', - expires_in: 1800, - scope: 'uma_protection', - } - }); - - expect(storage.find).toHaveBeenCalledTimes(1); - expect(storage.find).toHaveBeenLastCalledWith(CLIENT_REGISTRATION_STORAGE_TYPE, { - clientId: clientId, - clientSecret: clientSecret, - }); - expect(storage.findIds).toHaveBeenCalledTimes(1); - expect(storage.findIds).toHaveBeenLastCalledWith(PAT_STORAGE_TYPE, { registration: registrationId }); - expect(storage.create).toHaveBeenCalledTimes(1); - expect(storage.create).toHaveBeenLastCalledWith(PAT_STORAGE_TYPE, { - pat: response.body.access_token, - refreshToken: response.body.refresh_token, - expiration: now + 1800 * 1000, - registration: registrationId, - }); - - const jwk = await importJWK(publicKey, publicKey.alg); - const decodedToken = await jwtVerify(response.body.access_token, jwk); - expect(decodedToken.payload).toEqual({ - scope: 'uma_protection', - azp: clientId, - client_id: clientId, - iat: Math.floor(now/1000), - sub: userId, - iss: baseUrl, - aud: baseUrl, - exp: Math.floor(now/1000) + 1800, - jti: expect.any(String), - }) + expect(response).toEqual({ status: 201, body: { access_token: 'pat' }}); + expect(umaProtection.handleSafe).toHaveBeenCalledTimes(1); + expect(umaProtection.handleSafe).toHaveBeenLastCalledWith({ request }); + expect(negotiator.negotiate).toHaveBeenCalledTimes(0); + expect(refreshTokenHandler.handleSafe).toHaveBeenCalledTimes(0); }); - it('replaces the token for the given credentials if there is one.', async(): Promise => { - storage.findIds.mockResolvedValueOnce(['patId']); - const response = await handler.handle({ request }); - expect(response).toEqual({ - status: 201, - body: { - access_token: expect.any(String), - refresh_token: expect.any(String), - token_type: 'Bearer', - expires_in: 1800, - scope: 'uma_protection', - } - }); - expect(storage.create).toHaveBeenCalledTimes(0); - expect(storage.set).toHaveBeenCalledTimes(1); - expect(storage.set).toHaveBeenLastCalledWith(PAT_STORAGE_TYPE, { - id: 'patId', - pat: response.body.access_token, - refreshToken: response.body.refresh_token, - expiration: now + 1800 * 1000, - registration: registrationId, - }); + it('routes based on scope before checking if Token supports the grant type.', async(): Promise => { + umaProtection.handleSafe.mockResolvedValueOnce({ status: 400, body: { error: 'from-uma' }}); + await expect(handler.handle({ request })).resolves.toEqual({ status: 400, body: { error: 'from-uma' }}); + expect(umaProtection.handleSafe).toHaveBeenCalledTimes(1); }); }); - describe('using a refresh token', (): void => { - const refreshToken = 'refreshToken'; - const patId = 'patId'; - + describe('delegating standard refresh token requests', (): void => { beforeEach(async(): Promise => { - request.headers = { - authorization: `Basic ${encoded}`, - }; - request.body = { - grant_type: 'refresh_token', - refresh_token: refreshToken, - scope: 'uma_protection', - }; - - storage.find.mockImplementation((type): any => { - if (type === CLIENT_REGISTRATION_STORAGE_TYPE) { - return [{ id: registrationId, clientId, clientSecret, clientUri, userId }]; - } - return [{ id: patId, registration: registrationId, refreshToken: refreshToken }]; - }); - }); - - it('errors if no refresh token is provided.', async(): Promise => { request.body = { grant_type: 'refresh_token', - scope: 'uma_protection', + refresh_token: 'token', }; - await expect(handler.handle({ request })).rejects.toThrow('Missing refresh_token parameter'); - }); - - it('errors if no matching refresh token could be found.', async(): Promise => { - storage.find.mockResolvedValueOnce([]); - await expect(handler.handle({ request })).rejects.toThrow(`Unknown refresh token ${refreshToken}`); - }); - - it('errors if the authorization header is missing.', async(): Promise => { - delete request.headers.authorization; - await expect(handler.handle({ request })).rejects.toThrow(UnauthorizedHttpError); }); - it('errors if the scope is wrong.', async(): Promise => { - request.body = { grant_type: 'client_credentials' }; - await expect(handler.handle({ request })).rejects.toThrow(`Expected scope 'uma_protection'`); - }); - - it('errors for non-Basic authorization schemes.', async(): Promise => { - request.headers.authorization = `Bearer ${encoded}`; - await expect(handler.handle({ request })).rejects.toThrow(`Expected scheme 'Basic'`); - }); - - it('errors if the credentials are not known.', async(): Promise => { - storage.find.mockImplementation((type): any => { - if (type === CLIENT_REGISTRATION_STORAGE_TYPE) { - return []; - } - return [{ id: patId, registration: registrationId, refreshToken: refreshToken }]; - }); - await expect(handler.handle({ request })).rejects.toThrow(ForbiddenHttpError); - }); - - it('errors if the refresh token is not associated with these credentials.', async(): Promise => { - storage.find.mockImplementation((type): any => { - if (type === CLIENT_REGISTRATION_STORAGE_TYPE) { - return [{ id: 'wrongId', clientId, clientSecret, clientUri, userId }]; - } - return [{ id: patId, registration: registrationId, refreshToken: refreshToken }]; - }); - await expect(handler.handle({ request })).rejects.toThrow(`Wrong credentials for refresh token ${refreshToken}`); - }); - - it('generates a token response.', async(): Promise => { - const response = await handler.handle({ request }); - expect(response).toEqual({ - status: 201, - body: { - access_token: expect.any(String), - refresh_token: expect.any(String), - token_type: 'Bearer', - expires_in: 1800, - scope: 'uma_protection', - } - }); - - expect(storage.find).toHaveBeenCalledTimes(2); - expect(storage.find).nthCalledWith(1, PAT_STORAGE_TYPE, { refreshToken }); - expect(storage.find).nthCalledWith(2, CLIENT_REGISTRATION_STORAGE_TYPE, { - clientId: clientId, - clientSecret: clientSecret, - }); - expect(storage.create).toHaveBeenCalledTimes(0); - expect(storage.set).toHaveBeenCalledTimes(1); - expect(storage.set).toHaveBeenLastCalledWith(PAT_STORAGE_TYPE, { - id: 'patId', - pat: response.body.access_token, - refreshToken: response.body.refresh_token, - expiration: now + 1800 * 1000, - registration: registrationId, - }); + it('returns the delegated refresh response unchanged.', async(): Promise => { + await expect(handler.handle({ request })).resolves + .toEqual({ status: 200, body: { access_token: 'rpt', refresh_token: 'next' }}); + expect(refreshTokenHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(refreshTokenHandler.handleSafe).toHaveBeenLastCalledWith({ request }); + expect(umaProtection.handleSafe).toHaveBeenCalledTimes(0); + expect(negotiator.negotiate).toHaveBeenCalledTimes(0); }); }); }); diff --git a/packages/uma/test/unit/routes/UmaProtection.test.ts b/packages/uma/test/unit/routes/UmaProtection.test.ts new file mode 100644 index 00000000..be947f5d --- /dev/null +++ b/packages/uma/test/unit/routes/UmaProtection.test.ts @@ -0,0 +1,284 @@ +import { + AlgJwk, + ForbiddenHttpError, + IndexedStorage, + JwkGenerator, + UnauthorizedHttpError +} from '@solid/community-server'; +import { exportJWK, generateKeyPair, GenerateKeyPairResult, importJWK, jwtVerify } from 'jose'; +import { beforeAll, Mocked } from 'vitest'; +import { + CLIENT_REGISTRATION_STORAGE_DESCRIPTION, + CLIENT_REGISTRATION_STORAGE_TYPE +} from '../../../src/routes/ClientRegistration'; +import { + PAT_STORAGE_DESCRIPTION, + PAT_STORAGE_TYPE, + UmaProtection, + UMA_PROTECTION_SCOPE +} from '../../../src/routes/token/UmaProtection'; +import { HttpHandlerContext } from '../../../src/util/http/models/HttpHandler'; + +vi.useFakeTimers(); + +describe('UmaProtection', (): void => { + const now = Date.now(); + const clientUri = 'http://example.org'; + const baseUrl = 'http://example.com'; + const userId = 'userId'; + const registrationId = 'registrationId'; + const clientId = 'clientId'; + const clientSecret = 'sec ret'; + const encoded = Buffer.from('clientId:sec%20ret', 'utf8').toString('base64'); + const alg = 'ES256'; + let keys: GenerateKeyPairResult; + let publicKey: AlgJwk; + let privateKey: AlgJwk; + let context: HttpHandlerContext; + + let storage: Mocked>; + let keyGen: Mocked; + let handler: UmaProtection; + + beforeAll(async(): Promise => { + keys = await generateKeyPair(alg); + publicKey = { ...await exportJWK(keys.publicKey), alg }; + privateKey = { ...await exportJWK(keys.privateKey), alg }; + }); + + beforeEach(async(): Promise => { + context = { + request: { + url: new URL('http://example.com/token'), + parameters: {}, + method: 'POST', + headers: {}, + body: {}, + }, + }; + + storage = { + defineType: vi.fn(), + createIndex: vi.fn(), + find: vi.fn().mockResolvedValue([{ id: registrationId, clientId, clientSecret, clientUri, userId }]), + findIds: vi.fn().mockResolvedValue([]), + set: vi.fn(), + create: vi.fn(), + } as any; + + keyGen = { + alg, + getPublicKey: vi.fn().mockResolvedValue(publicKey), + getPrivateKey: vi.fn().mockResolvedValue(privateKey), + }; + + handler = new UmaProtection(storage as any, keyGen, baseUrl); + }); + + it('throws an error if the body is invalid.', async(): Promise => { + context.request.body = { ticket: 5 }; + await expect(handler.handle(context)).rejects + .toThrow('Invalid token request body: value is neither of the union types'); + }); + + it('throws an error if the scope is invalid.', async(): Promise => { + context.request.body = { grant_type: 'client_credentials' }; + await expect(handler.handle(context)).rejects.toThrow(`Expected scope '${UMA_PROTECTION_SCOPE}'`); + }); + + it('throws an error if the grant type is not supported.', async(): Promise => { + context.request.headers.authorization = `Basic ${encoded}`; + context.request.body = { grant_type: 'not supported', scope: UMA_PROTECTION_SCOPE }; + await expect(handler.handle(context)).rejects.toThrow('Unsupported grant_type not supported'); + }); + + describe('using client credentials', (): void => { + beforeEach(async(): Promise => { + context.request.headers = { + authorization: `Basic ${encoded}`, + }; + context.request.body = { + grant_type: 'client_credentials', + scope: UMA_PROTECTION_SCOPE, + }; + }); + + it('errors if the authorization header is missing.', async(): Promise => { + delete context.request.headers.authorization; + await expect(handler.handle(context)).rejects.toThrow(UnauthorizedHttpError); + }); + + it('errors for non-Basic authorization schemes.', async(): Promise => { + context.request.headers.authorization = `Bearer ${encoded}`; + await expect(handler.handle(context)).rejects.toThrow(`Expected scheme 'Basic'`); + }); + + it('errors if the credentials are not known.', async(): Promise => { + storage.find.mockResolvedValueOnce([]); + await expect(handler.handle(context)).rejects.toThrow(ForbiddenHttpError); + }); + + it('generates a token response.', async(): Promise => { + const response = await handler.handle(context); + expect(response).toEqual({ + status: 201, + body: { + access_token: expect.any(String), + refresh_token: expect.any(String), + token_type: 'Bearer', + expires_in: 1800, + } + }); + + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(CLIENT_REGISTRATION_STORAGE_TYPE, { + clientId, + clientSecret, + }); + expect(storage.findIds).toHaveBeenCalledTimes(1); + expect(storage.findIds).toHaveBeenLastCalledWith(PAT_STORAGE_TYPE, { registration: registrationId }); + expect(storage.create).toHaveBeenCalledTimes(1); + expect(storage.create).toHaveBeenLastCalledWith(PAT_STORAGE_TYPE, { + pat: response.body?.access_token, + refreshToken: response.body?.refresh_token, + expiration: now + 1800 * 1000, + registration: registrationId, + }); + + const jwk = await importJWK(publicKey, publicKey.alg); + const decodedToken = await jwtVerify(response.body!.access_token, jwk); + expect(decodedToken.payload).toEqual({ + scope: UMA_PROTECTION_SCOPE, + azp: clientId, + client_id: clientId, + iat: Math.floor(now / 1000), + sub: userId, + iss: baseUrl, + aud: baseUrl, + exp: Math.floor(now / 1000) + 1800, + jti: expect.any(String), + }); + }); + + it('replaces the token for the given credentials if there is one.', async(): Promise => { + storage.findIds.mockResolvedValueOnce(['patId']); + const response = await handler.handle(context); + expect(response).toEqual({ + status: 201, + body: { + access_token: expect.any(String), + refresh_token: expect.any(String), + token_type: 'Bearer', + expires_in: 1800, + } + }); + expect(storage.create).toHaveBeenCalledTimes(0); + expect(storage.set).toHaveBeenCalledTimes(1); + expect(storage.set).toHaveBeenLastCalledWith(PAT_STORAGE_TYPE, { + id: 'patId', + pat: response.body?.access_token, + refreshToken: response.body?.refresh_token, + expiration: now + 1800 * 1000, + registration: registrationId, + }); + }); + }); + + describe('using a refresh token', (): void => { + const refreshToken = 'refreshToken'; + const patId = 'patId'; + + beforeEach(async(): Promise => { + context.request.headers = { + authorization: `Basic ${encoded}`, + }; + context.request.body = { + grant_type: 'refresh_token', + refresh_token: refreshToken, + scope: UMA_PROTECTION_SCOPE, + }; + + storage.find.mockImplementation((type): any => { + if (type === CLIENT_REGISTRATION_STORAGE_TYPE) { + return [{ id: registrationId, clientId, clientSecret, clientUri, userId }]; + } + return [{ id: patId, registration: registrationId, refreshToken }]; + }); + }); + + it('errors if no refresh token is provided.', async(): Promise => { + context.request.body = { + grant_type: 'refresh_token', + scope: UMA_PROTECTION_SCOPE, + }; + await expect(handler.handle(context)).rejects.toThrow('Missing refresh_token parameter'); + }); + + it('errors if no matching refresh token could be found.', async(): Promise => { + storage.find.mockResolvedValueOnce([]); + await expect(handler.handle(context)).rejects.toThrow(`Unknown refresh token ${refreshToken}`); + }); + + it('errors if the authorization header is missing.', async(): Promise => { + delete context.request.headers.authorization; + await expect(handler.handle(context)).rejects.toThrow(UnauthorizedHttpError); + }); + + it('errors for non-Basic authorization schemes.', async(): Promise => { + context.request.headers.authorization = `Bearer ${encoded}`; + await expect(handler.handle(context)).rejects.toThrow(`Expected scheme 'Basic'`); + }); + + it('errors if the credentials are not known.', async(): Promise => { + storage.find.mockImplementation((type): any => { + if (type === CLIENT_REGISTRATION_STORAGE_TYPE) { + return []; + } + return [{ id: patId, registration: registrationId, refreshToken }]; + }); + await expect(handler.handle(context)).rejects.toThrow(ForbiddenHttpError); + }); + + it('errors if the refresh token is not associated with these credentials.', async(): Promise => { + storage.find.mockImplementation((type): any => { + if (type === CLIENT_REGISTRATION_STORAGE_TYPE) { + return [{ id: 'wrongId', clientId, clientSecret, clientUri, userId }]; + } + return [{ id: patId, registration: registrationId, refreshToken }]; + }); + await expect(handler.handle(context)).rejects.toThrow(`Wrong credentials for refresh token ${refreshToken}`); + }); + + it('generates a token response.', async(): Promise => { + const response = await handler.handle(context); + expect(response).toEqual({ + status: 201, + body: { + access_token: expect.any(String), + refresh_token: expect.any(String), + token_type: 'Bearer', + expires_in: 1800, + } + }); + + expect(storage.find).toHaveBeenCalledTimes(2); + expect(storage.find).nthCalledWith(1, PAT_STORAGE_TYPE, { refreshToken }); + expect(storage.find).nthCalledWith(2, CLIENT_REGISTRATION_STORAGE_TYPE, { + clientId, + clientSecret, + }); + expect(storage.create).toHaveBeenCalledTimes(0); + expect(storage.set).toHaveBeenCalledTimes(1); + expect(storage.set).toHaveBeenLastCalledWith(PAT_STORAGE_TYPE, { + id: patId, + pat: response.body?.access_token, + refreshToken: response.body?.refresh_token, + expiration: now + 1800 * 1000, + registration: registrationId, + }); + }); + }); +}); diff --git a/packages/uma/test/unit/tokens/StoredRefreshTokenIssuer.test.ts b/packages/uma/test/unit/tokens/StoredRefreshTokenIssuer.test.ts new file mode 100644 index 00000000..3ddc991c --- /dev/null +++ b/packages/uma/test/unit/tokens/StoredRefreshTokenIssuer.test.ts @@ -0,0 +1,39 @@ +import { KeyValueStorage } from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { RefreshInformation } from '../../../src/credentials/verify/RefreshTokenVerifier'; +import { StoredRefreshTokenIssuer } from '../../../src/tokens/StoredRefreshTokenIssuer'; + +const now = new Date(); +vi.useFakeTimers({ now }); + +vi.mock('node:crypto', () => ({ + randomUUID: vi.fn().mockReturnValue('refresh-token-id'), +})); + +describe('StoredRefreshTokenIssuer', (): void => { + const claims = { webid: 'https://alice.example/#me' }; + const permissions = [ { resource_id: 'id', resource_scopes: [ 'scope' ] } ]; + + let refreshStore: Mocked>; + let issuer: StoredRefreshTokenIssuer; + + beforeEach(async(): Promise => { + refreshStore = { + set: vi.fn(), + } as any; + + issuer = new StoredRefreshTokenIssuer(refreshStore, '7d'); + }); + + it('issues a refresh token and stores refresh metadata.', async(): Promise => { + const result = await issuer.issue(claims, permissions); + + expect(refreshStore.set).toHaveBeenCalledTimes(1); + expect(refreshStore.set).toHaveBeenLastCalledWith('refresh-token-id', { + claims, + permissions, + expiration: now.getTime() + 7 * 24 * 60 * 60 * 1000, + }); + expect(result).toBe('refresh-token-id'); + }); +}); diff --git a/packages/uma/test/unit/util/http/validate/PatRequestValidator.test.ts b/packages/uma/test/unit/util/http/validate/PatRequestValidator.test.ts index 77562f3c..e85a78dd 100644 --- a/packages/uma/test/unit/util/http/validate/PatRequestValidator.test.ts +++ b/packages/uma/test/unit/util/http/validate/PatRequestValidator.test.ts @@ -4,7 +4,7 @@ import { CLIENT_REGISTRATION_STORAGE_DESCRIPTION, CLIENT_REGISTRATION_STORAGE_TYPE } from '../../../../../src/routes/ClientRegistration'; -import { PAT_STORAGE_DESCRIPTION, PAT_STORAGE_TYPE } from '../../../../../src/routes/Token'; +import { PAT_STORAGE_DESCRIPTION, PAT_STORAGE_TYPE } from '../../../../../src/routes/token/UmaProtection'; import { HttpHandlerRequest } from '../../../../../src/util/http/models/HttpHandler'; import { PatRequestValidator } from '../../../../../src/util/http/validate/PatRequestValidator'; diff --git a/test/integration/Base.test.ts b/test/integration/Base.test.ts index 7474b55c..b8cd54ce 100644 --- a/test/integration/Base.test.ts +++ b/test/integration/Base.test.ts @@ -101,14 +101,17 @@ describe('A server setup', (): void => { }); describe('using ODRL authorization', (): void => { - const collectionResource = `http://localhost:${cssPort}/alice/resource.txt`; + const newResource = `http://localhost:${cssPort}/alice/resource.txt`; + const newResource2 = `http://localhost:${cssPort}/alice/private/resource.txt`; let wwwAuthenticateHeader: string; let ticket: string; let tokenEndpoint: string; - let jsonResponse: { access_token: string, token_type: string }; + let refreshToken: string; + let jsonResponse: { access_token: string, token_type: string, refresh_token?: string }; + let refreshedResponse: { access_token: string, token_type: string, refresh_token: string }; it('RS: sends a WWW-Authenticate response when access is private.', async(): Promise => { - const noTokenResponse = await fetch(collectionResource, { + const noTokenResponse = await fetch(newResource, { method: 'PUT', body: 'Some text ...' , }); @@ -158,6 +161,8 @@ describe('A server setup', (): void => { jsonResponse = await asRequestResponse.json() as any; expect(typeof jsonResponse.access_token).toBe('string'); expect(jsonResponse.token_type).toBe('Bearer'); + expect(typeof jsonResponse.refresh_token).toBe('string'); + refreshToken = jsonResponse.refresh_token!; const token = JSON.parse(Buffer.from(jsonResponse.access_token.split('.')[1], 'base64').toString()); expect(Array.isArray(token.permissions)).toBe(true); expect(token.permissions).toHaveLength(1); @@ -170,7 +175,7 @@ describe('A server setup', (): void => { }); it('RS: provides access when receiving a valid token.', async(): Promise => { - const response = await fetch(collectionResource, { + const response = await fetch(newResource, { method: 'PUT', headers: { 'Authorization': `${jsonResponse.token_type} ${jsonResponse.access_token}` }, body: 'Some text ...' , @@ -179,8 +184,57 @@ describe('A server setup', (): void => { expect(response.status).toBe(201); }); + it('AS: can refresh a token and rotates the refresh token.', async(): Promise => { + const refreshResponse = await fetch(tokenEndpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), + }); + + expect(refreshResponse.status).toBe(200); + refreshedResponse = await refreshResponse.json() as { + access_token: string, + token_type: string, + refresh_token: string, + }; + expect(typeof refreshedResponse.access_token).toBe('string'); + expect(refreshedResponse.token_type).toBe('Bearer'); + expect(typeof refreshedResponse.refresh_token).toBe('string'); + expect(refreshedResponse.refresh_token).not.toBe(refreshToken); + + const oldRefreshResponse = await fetch(tokenEndpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), + }); + expect(oldRefreshResponse.status).toBe(400); + + refreshToken = refreshedResponse.refresh_token; + }); + + it('RS: provides access when receiving the refreshed token.', async(): Promise => { + // Targeting a new resource as the token gives create access to /alice/, + // and newResource was created with the previous request. + const response = await fetch(newResource2, { + method: 'PUT', + headers: { + Authorization: `${refreshedResponse.token_type} ${refreshedResponse.access_token}`, + 'content-type': 'text/plain', + }, + body: 'Some text from refreshed token ...', + }); + + expect(response.status).toBe(201); + }); + it('RS: does not allow public read access to the new resource.', async(): Promise => { - const response = await fetch(collectionResource); + const response = await fetch(newResource); expect(response.status).toBe(401); }); @@ -197,7 +251,7 @@ describe('A server setup', (): void => { odrl:permission ex:publicAnonPermission . ex:publicAnonPermission a odrl:Permission ; odrl:action odrl:read , odrl:modify ; - odrl:target <${collectionResource}> ; + odrl:target <${newResource}> ; odrl:assignee ; odrl:assigner <${owner}> . `; @@ -209,14 +263,14 @@ describe('A server setup', (): void => { }); expect(policyResponse.status).toBe(201); - const putResponse = await fetch(collectionResource, { + const putResponse = await fetch(newResource, { method: 'PUT', headers: { 'content-type': 'text/plain' }, body: 'Some new text!', }); expect(putResponse.status).toBe(205); - const getResponse = await fetch(collectionResource); + const getResponse = await fetch(newResource); expect(getResponse.status).toBe(200); await expect(getResponse.text()).resolves.toEqual('Some new text!'); }); @@ -234,7 +288,7 @@ describe('A server setup', (): void => { odrl:permission ex:publicPermission . ex:publicPermission a odrl:Permission ; odrl:action odrl:read , odrl:modify ; - odrl:target <${collectionResource}> ; + odrl:target <${newResource}> ; odrl:assigner <${owner}> . `; @@ -245,14 +299,14 @@ describe('A server setup', (): void => { }); expect(policyResponse.status).toBe(201); - const putResponse = await fetch(collectionResource, { + const putResponse = await fetch(newResource, { method: 'PUT', headers: { 'content-type': 'text/plain' }, body: 'Some new text!', }); expect(putResponse.status).toBe(205); - const getResponse = await fetch(collectionResource); + const getResponse = await fetch(newResource); expect(getResponse.status).toBe(200); await expect(getResponse.text()).resolves.toEqual('Some new text!'); });