Skip to content
Merged
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
51 changes: 42 additions & 9 deletions documentation/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions packages/uma/config/credentials/verifiers/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
},
{
Expand Down
89 changes: 89 additions & 0 deletions packages/uma/src/credentials/verify/OidcVerifier.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {},
) {}

/** @inheritdoc */
public async verify(credential: Credential): Promise<ClaimSet> {
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 }
});
}
}
42 changes: 0 additions & 42 deletions packages/uma/src/credentials/verify/SolidOidcVerifier.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/uma/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
111 changes: 111 additions & 0 deletions packages/uma/test/unit/credentials/verify/OidcVerifier.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
decodeJwt.mockReturnValue({ ...decodedToken, aud: [ baseUrl, 'solid' ] });
});

it('returns the extracted WebID.', async(): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
await expect(verifier.verify(credential)).resolves.toEqual({
['urn:solidlab:uma:claims:types:webid']: 'sub',
});
});

it('returns the extracted client identifier.', async(): Promise<void> => {
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',
});
});
});
});

This file was deleted.

Loading