Skip to content

Commit

Permalink
fix: fallback for unavailable uma servers
Browse files Browse the repository at this point in the history
Signed-off-by: Wouter Termont <[email protected]>
  • Loading branch information
termontwouter committed Apr 4, 2024
1 parent 25dbf94 commit 2309560
Show file tree
Hide file tree
Showing 7 changed files with 126 additions and 49 deletions.
23 changes: 14 additions & 9 deletions packages/css/config/uma/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"@id": "urn:solid-server:default:StatusDependantServerConfigurator",
"@type": "StatusDependantServerConfigurator",
"dependants": [
{ "@id": "urn:solid-server:default:Fetcher" }
{ "@id": "urn:solid-server:default:UmaFetcher" }
],
"statusMap": [
{
Expand All @@ -35,21 +35,26 @@
"comment": "Returns the UMA ticket in case of an unauthorized request.",
"@id": "urn:solid-server:default:UmaClient",
"@type": "UmaClient",
"baseUrl": {
"@id": "urn:solid-server:default:variable:baseUrl"
},
"umaIdStore": {
"@id": "urn:solid-server:default:UmaIdStore",
"@type": "MemoryMapStorage"
},
"keyGen": {
"@id": "urn:solid-server:default:JwkGenerator"
},
"fetcher": {
"@id": "urn:solid-server:default:Fetcher",
"@id": "urn:solid-server:default:UmaFetcher",
"@type": "PausableFetcher",
"fetcher": {
"@type": "BaseFetcher"
"@type": "RetryingFetcher",
"fetcher": {
"@type": "SignedFetcher",
"fetcher": {
"@type": "BaseFetcher"
},
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"keyGen": { "@id": "urn:solid-server:default:JwkGenerator" }
},
"retries": 150,
"exponent": 3,
"retryOn": [401, 500]
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion packages/css/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@
},
"devDependencies": {
"@types/n3": "^1.16.4",
"@types/node": "^18.18.11"
"@types/node": "^18.18.11",
"fetch-retry": "^6.0.0"
},
"jest": {
"preset": "ts-jest",
Expand Down
2 changes: 2 additions & 0 deletions packages/css/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,7 @@ export * from './util/OwnerUtil';
export * from './util/fetch/Fetcher';
export * from './util/fetch/BaseFetcher';
export * from './util/fetch/PausableFetcher';
export * from './util/fetch/RetryingFetcher';
export * from './util/fetch/SignedFetcher';
export * from './util/fetch/StatusDependant';
export * from './util/fetch/StatusDependantServerConfigurator';
46 changes: 7 additions & 39 deletions packages/css/src/uma/UmaClient.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import crypto from 'node:crypto';
import {
type KeyValueStorage, type ResourceIdentifier,
AccessMap, getLoggerFor, InternalServerError, JwkGenerator,
} from "@solid/community-server";
import { type KeyValueStorage, type ResourceIdentifier, AccessMap, getLoggerFor } from "@solid/community-server";
import type { Fetcher } from "../util/fetch/Fetcher";
import type { ResourceDescription } from '@solidlab/uma';
import { httpbis, type SigningKey, type Request as SignRequest } from 'http-message-signatures';
import { JWTPayload, decodeJwt, createRemoteJWKSet, jwtVerify, JWTVerifyOptions } from "jose";

export interface Claims {
Expand Down Expand Up @@ -67,41 +62,14 @@ export class UmaClient {
protected readonly logger = getLoggerFor(this);

/**
* @param {JwkGenerator} keyGen - the generator providing the signing key
* @param {UmaVerificationOptions} options - options for JWT verification
*/
constructor(
protected baseUrl: string,
protected umaIdStore: KeyValueStorage<string, string>,
protected fetcher: Fetcher,
protected keyGen: JwkGenerator,
protected options: UmaVerificationOptions = {},
) {}

public async signedFetch(url: string, request: RequestInit & Omit<SignRequest, 'url'>): Promise<Response> {
const jwk = await this.keyGen.getPrivateKey();

const { alg, kid } = jwk;
if (alg === 'EdDSA') throw new InternalServerError('EdDSA signing is not supported');
if (alg === 'ES256K') throw new InternalServerError('ES256K signing is not supported');

const key: SigningKey = {
id: kid,
alg: alg,
async sign(data: BufferSource) {
const params = algMap[alg];
const key = await crypto.subtle.importKey('jwk', jwk, params, false, ['sign']);
return Buffer.from(await crypto.subtle.sign(params, key, data));
},
};

request.headers['Authorization'] = `HttpSig cred="${this.baseUrl}"`;

const signed = await httpbis.signMessage({ key, paramValues: { keyid: 'TODO' } }, { ...request, url });

return await this.fetcher.fetch(url, signed);
}

/**
* Method to fetch a ticket from the Permission Registration endpoint of the UMA Authorization Service.
*
Expand Down Expand Up @@ -129,7 +97,7 @@ export class UmaClient {
});
}

const response = await this.signedFetch(endpoint, {
const response = await this.fetcher.fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand Down Expand Up @@ -218,7 +186,7 @@ export class UmaClient {
throw new Error(message);
}

const res = await this.signedFetch(config.introspection_endpoint, {
const res = await this.fetcher.fetch(config.introspection_endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Expand All @@ -244,7 +212,7 @@ export class UmaClient {
*/
public async fetchUmaConfig(issuer: string): Promise<UmaConfig> {
const configUrl = issuer + UMA_DISCOVERY;
const res = await fetch(configUrl);
const res = await this.fetcher.fetch(configUrl);

if (res.status >= 400) {
throw new Error(`Unable to retrieve UMA Configuration for Authorization Server '${issuer}' from '${configUrl}'`);
Expand Down Expand Up @@ -291,7 +259,7 @@ export class UmaClient {
};

// do not await - registration happens in background to cope with errors etc.
this.signedFetch(endpoint, request).then(async resp => {
this.fetcher.fetch(endpoint, request).then(async resp => {
if (resp.status !== 201) {
throw new Error (`Resource registration request failed. ${await resp.text()}`);
}
Expand Down Expand Up @@ -326,10 +294,10 @@ export class UmaClient {
};

// do not await - registration happens in background to cope with errors etc.
this.signedFetch(endpoint, request).then(async _resp => {
this.fetcher.fetch(endpoint, request).then(async _resp => {
if (!umaId) throw new Error('Trying to delete unknown/unregistered resource; no UMA id found.');

await this.signedFetch(url, request);
await this.fetcher.fetch(url, request);
}).catch(error => {
// TODO: Do something useful on error
this.logger.warn(
Expand Down
28 changes: 28 additions & 0 deletions packages/css/src/util/fetch/RetryingFetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { getLoggerFor } from '@solid/community-server';
import type { FetchParams, Fetcher } from './Fetcher';
import retryFetcher from 'fetch-retry';

/**
* A {@link Fetcher} wrapper that retries failed fetches.
*/
export class RetryingFetcher implements Fetcher {
protected readonly logger = getLoggerFor(this);
protected readonly retryFetch: (...args: FetchParams) => Promise<Response>;

constructor(
protected fetcher: Fetcher,
retries: number = 150,
exponent: number = 3,
retryOn: number[] = [],
) {
this.retryFetch = retryFetcher(fetcher.fetch.bind(fetcher), {
retryOn,
retries,
retryDelay: (attempt) => Math.pow(exponent, attempt) * 1000,
});
}

async fetch(...args: FetchParams): Promise<Response> {
return this.retryFetch(...args)
}
}
65 changes: 65 additions & 0 deletions packages/css/src/util/fetch/SignedFetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { InternalServerError, getLoggerFor, type JwkGenerator } from '@solid/community-server';
import type { Fetcher } from './Fetcher';
import { httpbis, type SigningKey } from 'http-message-signatures';

const algMap = {
'ES256': { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' },
'ES384': { name: 'ECDSA', namedCurve: 'P-384', hash: 'SHA-384' },
'ES512': { name: 'ECDSA', namedCurve: 'P-512', hash: 'SHA-512' },
'HS256': { name: 'HMAC', hash: 'SHA-256' },
'HS384': { name: 'HMAC', hash: 'SHA-384' },
'HS512': { name: 'HMAC', hash: 'SHA-512' },
'PS256': { name: 'RSASSA-PSS', hash: 'SHA-256' },
'PS384': { name: 'RSASSA-PSS', hash: 'SHA-384' },
'PS512': { name: 'RSASSA-PSS', hash: 'SHA-512' },
'RS256': { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
'RS384': { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-384' },
'RS512': { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-512' },
}

/**
* A {@link Fetcher} wrapper that signes requests.
*/
export class SignedFetcher implements Fetcher {
protected readonly logger = getLoggerFor(this);

constructor(
protected fetcher: Fetcher,
protected baseUrl: string,
protected keyGen: JwkGenerator,
) {}

public async fetch(input: NodeJS.fetch.RequestInfo, init?: RequestInit): Promise<Response> {
const jwk = await this.keyGen.getPrivateKey();

const { alg, kid } = jwk;
if (alg === 'EdDSA') throw new InternalServerError('EdDSA signing is not supported');
if (alg === 'ES256K') throw new InternalServerError('ES256K signing is not supported');

const key: SigningKey = {
id: kid,
alg: alg,
async sign(data: BufferSource) {
const params = algMap[alg];
const key = await crypto.subtle.importKey('jwk', jwk, params, false, ['sign']);
return Buffer.from(await crypto.subtle.sign(params, key, data));
},
};

const url = input instanceof URL ? input.href : input instanceof Request ? input.url : input as string;

const request = {
...init ?? {},
url,
method: init?.method ?? 'GET',
headers: {} as Record<string, string>
}
;
new Headers(init?.headers).forEach((value, key) => request.headers[key] = value);
request.headers['Authorization'] = `HttpSig cred="${this.baseUrl}"`;

const signed = await httpbis.signMessage({ key, paramValues: { keyid: 'TODO' } }, request);

return await this.fetcher.fetch(url, signed);
}
}
8 changes: 8 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4584,6 +4584,7 @@ __metadata:
"@types/node": "npm:^18.18.11"
componentsjs: "npm:^5.4.2"
cross-fetch: "npm:^4.0.0"
fetch-retry: "npm:^6.0.0"
http-message-signatures: "npm:^1.0.4"
jose: "npm:^5.2.2"
n3: "npm:^1.17.2"
Expand Down Expand Up @@ -7097,6 +7098,13 @@ __metadata:
languageName: node
linkType: hard

"fetch-retry@npm:^6.0.0":
version: 6.0.0
resolution: "fetch-retry@npm:6.0.0"
checksum: 10c0/8e275b042ff98041236d30b71966f24c34ff19f957bb0f00e664754bd63d0dfb5122d091e7d5bca21f6370d88a1713d22421b33471305d7b86d6799427278802
languageName: node
linkType: hard

"fetch-sparql-endpoint@npm:^4.0.0, fetch-sparql-endpoint@npm:^4.1.0":
version: 4.1.0
resolution: "fetch-sparql-endpoint@npm:4.1.0"
Expand Down

0 comments on commit 2309560

Please sign in to comment.