diff --git a/documentation/getting-started.md b/documentation/getting-started.md index 09a39710..191e09ce 100644 --- a/documentation/getting-started.md +++ b/documentation/getting-started.md @@ -36,7 +36,8 @@ so some information might change depending on which version and branch you're us + [Generating a ticket](#generating-a-ticket) - [Publicly accessible resources](#publicly-accessible-resources) + [Exchange ticket](#exchange-ticket) - - [Claim security](#claim-security) + - [Authentication methods](#authentication-methods) + - [Customizing OIDC verification](#customizing-oidc-verification) + [Generate token](#generate-token) + [Use token](#use-token) * [Policies](#policies) @@ -225,15 +226,47 @@ The `claim_token_format` explains to the AS how the `claim_token` should be inte In this case, this is a custom format designed for this server, where the token is a URL-encoded WebID. -#### Claim security +#### Authentication methods -In the above body, the claim token format is a string representing a WebID. -No actual authentication or verification takes place here, -meaning anyone can insert any WebID they want. -This is great for quickly testing things out, -but less good for security and testing actual authentication. -The AS also supports OIDC tokens as defined in the [Solid OIDC specification](https://solid.github.io/solid-oidc/). -In that case, the `claim_token_format` should be `http://openid.net/specs/openid-connect-core-1_0.html#IDToken`. +The above claim token format indicates that the claim token should be interpreted as a valid WebID. +No validation is done, so this should only be used for debugging and development. + +It is also possible to use `http://openid.net/specs/openid-connect-core-1_0.html#IDToken` as token format instead. +In that case the body is expected to be an OIDC ID token. +Both Solid and standard OIDC tokens are supported. +In case of standard tokens, the value of the `sub` field will be used to match the assignee in the policies. + +#### Customizing OIDC verification + +Several configuration options can be added to further restrict authentication when using OIDC tokens, +by adding entries to the Components.js configuration of the UMA server. +All options of the [verification function](https://github.com/panva/jose/blob/main/docs/jwt/verify/interfaces/JWTVerifyOptions.md) +can be added. +For example, the max age of a token can be set to 60s by adding the following block: +```json +{ + "@id": "urn:uma:default:OidcVerifier", + "verifyOptions": [ + { + "OidcVerifier:_verifyOptions_key": "maxTokenAge", + "OidcVerifier:_verifyOptions_value": 60 + } + ] +} +``` +Other options can be added in a similar fashion by adding entries to the above array. + +It is also possible to restrict which token issuers are allowed. +This can be done by adding the following configuration: +```json +{ + "@id": "urn:uma:default:OidcVerifier", + "allowedIssuers": [ + "http://example.com/idp/", + "http://example.org/issuer/" + ] +} +``` ### Generate token diff --git a/packages/uma/config/credentials/verifiers/default.json b/packages/uma/config/credentials/verifiers/default.json index cec74d3d..51c11e45 100644 --- a/packages/uma/config/credentials/verifiers/default.json +++ b/packages/uma/config/credentials/verifiers/default.json @@ -17,8 +17,9 @@ { "TypedVerifier:_verifiers_key": "http://openid.net/specs/openid-connect-core-1_0.html#IDToken", "TypedVerifier:_verifiers_value": { - "@id": "urn:uma:default:SolidOidcVerifier", - "@type": "SolidOidcVerifier" + "@id": "urn:uma:default:OidcVerifier", + "@type": "OidcVerifier", + "baseUrl": { "@id": "urn:uma:variables:baseUrl" } } }, { diff --git a/packages/uma/src/credentials/verify/OidcVerifier.ts b/packages/uma/src/credentials/verify/OidcVerifier.ts new file mode 100644 index 00000000..a1b24753 --- /dev/null +++ b/packages/uma/src/credentials/verify/OidcVerifier.ts @@ -0,0 +1,89 @@ +import { createSolidTokenVerifier } from '@solid/access-token-verifier'; +import { BadRequestHttpError } from '@solid/community-server'; +import { getLoggerFor } from 'global-logger-factory'; +import { createRemoteJWKSet, decodeJwt, JWTPayload, jwtVerify, JWTVerifyOptions } from 'jose'; +import { CLIENTID, WEBID } from '../Claims'; +import { ClaimSet } from '../ClaimSet'; +import { Credential } from '../Credential'; +import { OIDC } from '../Formats'; +import { Verifier } from './Verifier'; + +/** + * A Verifier for OIDC ID Tokens. + * + * The `allowedIssuers` list can be used to only allow tokens from these issuers. + * Default is an empty list, which allows all issuers. + */ +export class OidcVerifier implements Verifier { + protected readonly logger = getLoggerFor(this); + + private readonly verifyToken = createSolidTokenVerifier(); + + public constructor( + protected readonly baseUrl: string, + protected readonly allowedIssuers: string[] = [], + protected readonly verifyOptions: Record = {}, + ) {} + + /** @inheritdoc */ + public async verify(credential: Credential): Promise { + this.logger.debug(`Verifying credential ${JSON.stringify(credential)}`); + if (credential.format !== OIDC) { + throw new BadRequestHttpError(`Token format ${credential.format} does not match this processor's format.`); + } + + // We first need to determine if this is a Solid OIDC token or a standard one + const unsafeDecoded = decodeJwt(credential.token); + const isSolidTOken = Array.isArray(unsafeDecoded.aud) && unsafeDecoded.aud.includes('solid'); + + try { + this.validateToken(unsafeDecoded); + if (isSolidTOken) { + return await this.verifySolidToken(credential.token); + } else { + return await this.verifyStandardToken(credential.token, unsafeDecoded.iss!); + } + } catch (error: unknown) { + const message = `Error verifying OIDC ID Token: ${(error as Error).message}`; + + this.logger.debug(message); + throw new BadRequestHttpError(message); + } + } + + protected validateToken(payload: JWTPayload): void { + if (payload.aud !== this.baseUrl && !(Array.isArray(payload.aud) && payload.aud.includes(this.baseUrl))) { + throw new BadRequestHttpError('This server is not valid audience for the token'); + } + if (!payload.iss || this.allowedIssuers.length > 0 && !this.allowedIssuers.includes(payload.iss)) { + throw new BadRequestHttpError('Unsupported issuer'); + } + } + + protected async verifySolidToken(token: string): Promise<{ [WEBID]: string, [CLIENTID]?: string }> { + const claims = await this.verifyToken(`Bearer ${token}`); + + this.logger.info(`Authenticated via a Solid OIDC. ${JSON.stringify(claims)}`); + + return ({ + // TODO: would have to use different value than "WEBID" + // TODO: still want to use WEBID as external value potentially? + [WEBID]: claims.webid, + ...claims.client_id && { [CLIENTID]: claims.client_id } + }); + } + + protected async verifyStandardToken(token: string, issuer: string): + Promise<{ [WEBID]: string, [CLIENTID]?: string }> { + const jwkSet = createRemoteJWKSet(new URL(issuer)); + const decoded = await jwtVerify(token, jwkSet, this.verifyOptions); + if (!decoded.payload.sub) { + throw new BadRequestHttpError('Invalid OIDC token: missing `sub` claim'); + } + const client = decoded.payload.azp as string | undefined; + return ({ + [WEBID]: decoded.payload.sub, + ...client && { [CLIENTID]: client } + }); + } +} diff --git a/packages/uma/src/credentials/verify/SolidOidcVerifier.ts b/packages/uma/src/credentials/verify/SolidOidcVerifier.ts deleted file mode 100644 index 57c728d2..00000000 --- a/packages/uma/src/credentials/verify/SolidOidcVerifier.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createSolidTokenVerifier } from '@solid/access-token-verifier'; -import { BadRequestHttpError } from '@solid/community-server'; -import { getLoggerFor } from 'global-logger-factory'; -import { CLIENTID, WEBID } from '../Claims'; -import { ClaimSet } from '../ClaimSet'; -import { Credential } from '../Credential'; -import { OIDC } from '../Formats'; -import { Verifier } from './Verifier'; - -/** - * A Verifier for OIDC ID Tokens. - */ -export class SolidOidcVerifier implements Verifier { - protected readonly logger = getLoggerFor(this); - - private readonly verifyToken = createSolidTokenVerifier(); - - /** @inheritdoc */ - public async verify(credential: Credential): Promise { - this.logger.debug(`Verifying credential ${JSON.stringify(credential)}`); - if (credential.format !== OIDC) { - throw new BadRequestHttpError(`Token format ${credential.format} does not match this processor's format.`); - } - - try { - const claims = await this.verifyToken(`Bearer ${credential.token}`); - - this.logger.info(`Authenticated via a Solid OIDC. ${JSON.stringify(claims)}`); - - return ({ // TODO: keep issuer (and other metadata) for validation ?? - [WEBID]: claims.webid, - ...claims.client_id && { [CLIENTID]: claims.client_id } - }); - - } catch (error: unknown) { - const message = `Error verifying OIDC ID Token: ${(error as Error).message}`; - - this.logger.debug(message); - throw new BadRequestHttpError(message); - } - } -} diff --git a/packages/uma/src/index.ts b/packages/uma/src/index.ts index 7105147d..3cbc409c 100644 --- a/packages/uma/src/index.ts +++ b/packages/uma/src/index.ts @@ -8,7 +8,7 @@ export * from './credentials/Credential'; export * from './credentials/verify/Verifier'; export * from './credentials/verify/TypedVerifier'; export * from './credentials/verify/UnsecureVerifier'; -export * from './credentials/verify/SolidOidcVerifier'; +export * from './credentials/verify/OidcVerifier'; export * from './credentials/verify/JwtVerifier'; // Dialog diff --git a/packages/uma/test/unit/credentials/verify/OidcVerifier.test.ts b/packages/uma/test/unit/credentials/verify/OidcVerifier.test.ts new file mode 100644 index 00000000..e9b626ad --- /dev/null +++ b/packages/uma/test/unit/credentials/verify/OidcVerifier.test.ts @@ -0,0 +1,111 @@ +import * as accessTokenVerifier from '@solid/access-token-verifier'; +import { JWTPayload } from 'jose'; +import * as jose from 'jose'; +import { MockInstance } from 'vitest'; +import { Credential } from '../../../../src/credentials/Credential'; +import { OidcVerifier } from '../../../../src/credentials/verify/OidcVerifier'; + +vi.mock('jose', () => ({ + createRemoteJWKSet: vi.fn(), + decodeJwt: vi.fn(), + jwtVerify: vi.fn(), +})); + +describe('OidcVerifier', (): void => { + const issuer = 'http://example.org/issuer'; + const baseUrl = 'http://example.com/uma'; + const credential: Credential = { + format: 'http://openid.net/specs/openid-connect-core-1_0.html#IDToken', + token: 'token', + }; + + const decodedToken: JWTPayload = { + sub: 'sub', + iss: issuer, + aud: baseUrl, + }; + const remoteKeySet = 'remoteKeySet'; + const decodeJwt = vi.spyOn(jose, 'decodeJwt'); + const jwtVerify = vi.spyOn(jose, 'jwtVerify'); + const createRemoteJWKSet = vi.spyOn(jose, 'createRemoteJWKSet'); + const verifierMock = vi.fn(); + vi.spyOn(accessTokenVerifier, 'createSolidTokenVerifier').mockReturnValue(verifierMock); + let verifier: OidcVerifier; + + beforeEach(async(): Promise => { + vi.clearAllMocks(); + decodeJwt.mockReturnValue(decodedToken); + jwtVerify.mockResolvedValue({ payload: decodedToken } as any); + createRemoteJWKSet.mockReturnValue(remoteKeySet as any); + + verifierMock.mockResolvedValue({ + webid: 'webId', + client_id: 'clientId' + }); + + verifier = new OidcVerifier(baseUrl) + }); + + it('errors on non-OIDC credentials.', async(): Promise => { + await expect(verifier.verify({ format: 'wrong', token: 'token' })).rejects + .toThrow("Token format wrong does not match this processor's format."); + }); + + it('errors if the server is not part of the audience.', async(): Promise => { + decodeJwt.mockReturnValue({ ...decodedToken, aud: 'wrong' }); + await expect(verifier.verify(credential)).rejects.toThrow('This server is not valid audience for the token'); + + decodeJwt.mockReturnValue({ ...decodedToken, aud: undefined }); + await expect(verifier.verify(credential)).rejects.toThrow('This server is not valid audience for the token'); + }); + + it('errors if the issuer is not allowed.', async(): Promise => { + verifier = new OidcVerifier(baseUrl, [ 'otherIssuer' ]); + await expect(verifier.verify(credential)).rejects.toThrow('Unsupported issuer'); + + verifier = new OidcVerifier(baseUrl, [ issuer ]); + await expect(verifier.verify(credential)).resolves.toEqual({ + ['urn:solidlab:uma:claims:types:webid']: 'sub', + }); + }); + + describe('parsing a Solid OIDC token', (): void => { + beforeEach(async(): Promise => { + decodeJwt.mockReturnValue({ ...decodedToken, aud: [ baseUrl, 'solid' ] }); + }); + + it('returns the extracted WebID.', async(): Promise => { + await expect(verifier.verify(credential)).resolves.toEqual({ + ['urn:solidlab:uma:claims:types:webid']: 'webId', + ['urn:solidlab:uma:claims:types:clientid']: 'clientId', + }); + }); + + it('throws an error if the token could not be verified.', async(): Promise => { + verifierMock.mockRejectedValueOnce(new Error('bad data')); + await expect(verifier.verify(credential)).rejects.toThrow('Error verifying OIDC ID Token: bad data'); + }); + }); + + describe('parsing a standard OIDC token', (): void => { + it('errors if the sub claim is missing', async(): Promise => { + jwtVerify.mockResolvedValue({ payload: { ...decodedToken, sub: undefined } } as any); + await expect(verifier.verify(credential)).rejects.toThrow('Invalid OIDC token: missing `sub` claim'); + }); + + it('returns the extracted identity.', async(): Promise => { + await expect(verifier.verify(credential)).resolves.toEqual({ + ['urn:solidlab:uma:claims:types:webid']: 'sub', + }); + }); + + it('returns the extracted client identifier.', async(): Promise => { + jwtVerify.mockResolvedValue({ payload: { ...decodedToken, azp: 'client' } } as any); + + await expect(verifier.verify(credential)).resolves.toEqual({ + ['urn:solidlab:uma:claims:types:webid']: 'sub', + ['urn:solidlab:uma:claims:types:clientid']: 'client', + }); + }); + }); +}); diff --git a/packages/uma/test/unit/credentials/verify/SolidOidcVerifier.test.ts b/packages/uma/test/unit/credentials/verify/SolidOidcVerifier.test.ts deleted file mode 100644 index 840b602f..00000000 --- a/packages/uma/test/unit/credentials/verify/SolidOidcVerifier.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import * as accessTokenVerifier from '@solid/access-token-verifier'; -import { Credential } from '../../../../src/credentials/Credential'; -import { SolidOidcVerifier } from '../../../../src/credentials/verify/SolidOidcVerifier'; - -describe('SolidOidcVerifier', (): void => { - const credential: Credential = { - format: 'http://openid.net/specs/openid-connect-core-1_0.html#IDToken', - token: 'token', - }; - - const verifierMock = vi.fn(); - vi.spyOn(accessTokenVerifier, 'createSolidTokenVerifier').mockReturnValue(verifierMock); - let verifier: SolidOidcVerifier; - - beforeEach(async(): Promise => { - vi.clearAllMocks(); - verifierMock.mockResolvedValue({ - webid: 'webId', - client_id: 'clientId', - aud: 'solid', - iss: 'issuer', - iat: 5, - exp: 6, - }); - - verifier = new SolidOidcVerifier() - }); - - it('errors on non-OIDC credentials.', async(): Promise => { - await expect(verifier.verify({ format: 'wrong', token: 'token' })).rejects - .toThrow("Token format wrong does not match this processor's format."); - }); - - it('returns the extracted WebID.', async(): Promise => { - await expect(verifier.verify(credential)).resolves.toEqual({ - ['urn:solidlab:uma:claims:types:webid']: 'webId', - ['urn:solidlab:uma:claims:types:clientid']: 'clientId', - }); - }); - - it('throws an error if the token could not be verified.', async(): Promise => { - verifierMock.mockRejectedValueOnce(new Error('bad data')); - await expect(verifier.verify(credential)).rejects.toThrow('Error verifying OIDC ID Token: bad data'); - }); -}); diff --git a/test/integration/Oidc.test.ts b/test/integration/Oidc.test.ts new file mode 100644 index 00000000..3fa706ac --- /dev/null +++ b/test/integration/Oidc.test.ts @@ -0,0 +1,202 @@ +import { AlgJwk, App, CachedJwkGenerator, MemoryMapStorage } from '@solid/community-server'; +import { setGlobalLoggerFactory, WinstonLoggerFactory } from 'global-logger-factory'; +import { importJWK, SignJWT } from 'jose'; +import { randomUUID } from 'node:crypto'; +import { createServer, Server } from 'node:http'; +import path from 'node:path'; +import { getDefaultCssVariables, getPorts, instantiateFromConfig } from '../util/ServerUtil'; +import { findTokenEndpoint, noTokenFetch } from '../util/UmaUtil'; + +const [ cssPort, umaPort ] = getPorts('Policies'); +const idpPort = umaPort + 100; + +describe('A server supporting OIDC tokens', (): void => { + const webId = 'http://example.com/profile/card#me'; + let privateKey: AlgJwk; + let umaApp: App; + let cssApp: App; + let idp: Server; + const idpUrl = `http://localhost:${idpPort}/`; + const policyEndpoint = `http://localhost:${umaPort}/uma/policies`; + const oidcFormat = 'http://openid.net/specs/openid-connect-core-1_0.html#IDToken'; + + beforeAll(async(): Promise => { + setGlobalLoggerFactory(new WinstonLoggerFactory('off')); + + umaApp = await instantiateFromConfig( + 'urn:uma:default:App', + path.join(__dirname, '../../packages/uma/config/default.json'), + { + 'urn:uma:variables:port': umaPort, + 'urn:uma:variables:baseUrl': `http://localhost:${umaPort}/uma`, + 'urn:uma:variables:eyePath': 'eye', + } + ) as App; + + cssApp = await instantiateFromConfig( + 'urn:solid-server:default:App', + path.join(__dirname, '../../packages/css/config/default.json'), + { + ...getDefaultCssVariables(cssPort), + 'urn:solid-server:uma:variable:AuthorizationServer': `http://localhost:${umaPort}/`, + 'urn:solid-server:default:variable:seedConfig': path.join(__dirname, '../../packages/css/config/seed.json'), + }, + ) as App; + + const generator = new CachedJwkGenerator('ES256', 'jwks', new MemoryMapStorage()); + privateKey = { ...await generator.getPrivateKey(), kid: 'kid' }; + const publicKey = { ...await generator.getPublicKey(), kid: 'kid' } + idp = createServer((req, res) => { + console.log(req.url); + if (req.url!.endsWith('/card')) { + res.writeHead(200, { 'content-type': 'text/turtle' }); + res.end(` + @prefix foaf: . + @prefix solid: . + + <> + a foaf:PersonalProfileDocument; + foaf:primaryTopic <#me>. + + <#me> + solid:oidcIssuer <${idpUrl}>; + a foaf:Person.`); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + if (req.url!.endsWith('/.well-known/openid-configuration')) { + res.end(JSON.stringify({ jwks_uri: idpUrl })); + return; + } + // Exposing private keys is fine right + res.end(JSON.stringify({ keys: [ publicKey ] })); + }); + idp.listen(idpPort); + + await Promise.all([umaApp.start(), cssApp.start()]); + }); + + describe('accessing a resource using a standard OIDC token.', (): void => { + const resource = `http://localhost:${cssPort}/alice/standard`; + const sub = '123456'; + const policy = ` + @prefix ex: . + @prefix odrl: . + @prefix dct: . + ex:policyStandard a odrl:Set; + odrl:uid ex:policyStandard ; + odrl:permission ex:permissionStandard . + + ex:permissionStandard a odrl:Permission ; + odrl:assignee <${sub}> ; + odrl:assigner <${webId}>; + odrl:action odrl:read , odrl:create , odrl:modify ; + odrl:target .`; + + it('can set up the policy.', async(): Promise => { + const response = await fetch(policyEndpoint, { + method: 'POST', + headers: { authorization: webId, 'content-type': 'text/turtle' }, + body: policy, + }); + expect(response.status).toBe(201); + }); + + it('can get an access token.', async(): Promise => { + const { as_uri, ticket } = await noTokenFetch(resource, { + method: 'PUT', + headers: { 'content-type': 'text/plain' }, + body: 'hello', + }); + const endpoint = await findTokenEndpoint(as_uri); + + const jwk = await importJWK(privateKey, privateKey.alg); + const jwt = await new SignJWT({}) + .setSubject(sub) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .setIssuedAt() + .setIssuer(idpUrl) + .setAudience(`http://localhost:${umaPort}/uma`) + .setJti(randomUUID()) + .sign(jwk); + + const content: Record = { + grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket', + ticket: ticket, + claim_token: jwt, + claim_token_format: oidcFormat, + }; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(content), + }); + expect(response.status).toBe(200); + }); + }); + + + describe('accessing a resource using a Solid OIDC token.', (): void => { + const resource = `http://localhost:${cssPort}/alice/standard`; + // Using dummy server so we can spoof WebID + const alice = idpUrl + 'alice/profile/card#me'; + const policy = ` + @prefix ex: . + @prefix odrl: . + @prefix dct: . + ex:policySolid a odrl:Set; + odrl:uid ex:policySolid ; + odrl:permission ex:permissionSolid . + + ex:permissionSolid a odrl:Permission ; + odrl:assignee <${alice}> ; + odrl:assigner <${webId}>; + odrl:action odrl:read , odrl:create , odrl:modify ; + odrl:target .`; + + it('can set up the policy.', async(): Promise => { + const response = await fetch(policyEndpoint, { + method: 'POST', + headers: { authorization: webId, 'content-type': 'text/turtle' }, + body: policy, + }); + expect(response.status).toBe(201); + }); + + // TODO: might want a test with an actual token from the RS IDP, but would require more steps and dependencies + it('can get an access token.', async(): Promise => { + const { as_uri, ticket } = await noTokenFetch(resource, { + method: 'PUT', + headers: { 'content-type': 'text/plain' }, + body: 'hello', + }); + const endpoint = await findTokenEndpoint(as_uri); + + const jwk = await importJWK(privateKey, privateKey.alg); + const jwt = await new SignJWT({ webid: alice }) + .setSubject(alice) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .setIssuedAt() + .setIssuer(idpUrl) + .setAudience([ 'solid', `http://localhost:${umaPort}/uma` ]) + .setJti(randomUUID()) + .setExpirationTime(Date.now() + 5000) + .sign(jwk); + + const content: Record = { + grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket', + ticket: ticket, + claim_token: jwt, + claim_token_format: oidcFormat, + }; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(content), + }); + expect(response.status).toBe(200); + }); + }); +}); diff --git a/test/util/ServerUtil.ts b/test/util/ServerUtil.ts index b72b9900..e7e10292 100644 --- a/test/util/ServerUtil.ts +++ b/test/util/ServerUtil.ts @@ -6,6 +6,7 @@ const portNames = [ 'Base', 'Demo', 'ODRL', + 'OIDC', 'Policies', ] as const;