Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/uma/config/credentials/verifiers/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 7 additions & 1 deletion packages/uma/config/dialog/negotiators/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
}
}
]
Expand Down
16 changes: 13 additions & 3 deletions packages/uma/config/routes/tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
4 changes: 4 additions & 0 deletions packages/uma/config/tokens/storage/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
{
"@id": "urn:uma:default:TokenStore",
"@type": "MemoryMapStorage"
},
{
"@id": "urn:uma:default:RefreshTokenStore",
"@type": "MemoryMapStorage"
}
]
}
1 change: 1 addition & 0 deletions packages/uma/src/credentials/Formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
42 changes: 42 additions & 0 deletions packages/uma/src/credentials/verify/RefreshTokenVerifier.ts
Original file line number Diff line number Diff line change
@@ -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<string, RefreshInformation>,
) {
}

public async verify(credential: Credential): Promise<ClaimSet> {
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;
}
}
15 changes: 11 additions & 4 deletions packages/uma/src/dialog/BaseNegotiator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string, Ticket>,
protected ticketingStrategy: TicketingStrategy,
protected tokenFactory: TokenFactory,
protected readonly verifier: Verifier,
protected readonly ticketStore: KeyValueStorage<string, Ticket>,
protected readonly ticketingStrategy: TicketingStrategy,
protected readonly tokenFactory: TokenFactory,
protected readonly refreshTokenIssuer?: RefreshTokenIssuer,
) {}

/**
Expand Down Expand Up @@ -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())

Expand All @@ -68,6 +74,7 @@ export class BaseNegotiator implements Negotiator {
return ({
access_token: token,
token_type: tokenType,
refresh_token: refreshToken,
});
}

Expand Down
5 changes: 5 additions & 0 deletions packages/uma/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -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
Expand Down
137 changes: 9 additions & 128 deletions packages/uma/src/routes/Token.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, never>>,
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<void> {
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<HttpHandlerResponse<any>> {
Expand All @@ -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}`);
}
Expand All @@ -98,88 +63,4 @@ export class TokenRequestHandler extends HttpHandler {
throw e; // TODO: distinguish other errors
}
}

protected async handlePatRequest(params: DialogInput, authorization?: string): Promise<HttpHandlerResponse<any>> {
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<HttpHandlerResponse<any>> {
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<TypeObject<typeof CLIENT_REGISTRATION_STORAGE_DESCRIPTION>> {
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<typeof CLIENT_REGISTRATION_STORAGE_DESCRIPTION>, id?: string):
Promise<HttpHandlerResponse<any>> {
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',
}
}
}
}
Loading
Loading