Skip to content

Commit bcce24f

Browse files
committed
feat: Support standard OIDC tokens
1 parent 10a6c62 commit bcce24f

File tree

9 files changed

+449
-99
lines changed

9 files changed

+449
-99
lines changed

documentation/getting-started.md

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ so some information might change depending on which version and branch you're us
3636
+ [Generating a ticket](#generating-a-ticket)
3737
- [Publicly accessible resources](#publicly-accessible-resources)
3838
+ [Exchange ticket](#exchange-ticket)
39-
- [Claim security](#claim-security)
39+
- [Authentication methods](#authentication-methods)
40+
- [Customizing OIDC verification](#customizing-oidc-verification)
4041
+ [Generate token](#generate-token)
4142
+ [Use token](#use-token)
4243
* [Policies](#policies)
@@ -225,15 +226,47 @@ The `claim_token_format` explains to the AS how the `claim_token` should be inte
225226
In this case, this is a custom format designed for this server,
226227
where the token is a URL-encoded WebID.
227228

228-
#### Claim security
229+
#### Authentication methods
229230

230-
In the above body, the claim token format is a string representing a WebID.
231-
No actual authentication or verification takes place here,
232-
meaning anyone can insert any WebID they want.
233-
This is great for quickly testing things out,
234-
but less good for security and testing actual authentication.
235-
The AS also supports OIDC tokens as defined in the [Solid OIDC specification](https://solid.github.io/solid-oidc/).
236-
In that case, the `claim_token_format` should be `http://openid.net/specs/openid-connect-core-1_0.html#IDToken`.
231+
The above claim token format indicates that the claim token should be interpreted as a valid WebID.
232+
No validation is done, so this should only be used for debugging and development.
233+
234+
It is also possible to use `http://openid.net/specs/openid-connect-core-1_0.html#IDToken` as token format instead.
235+
In that case the body is expected to be an OIDC ID token.
236+
Both Solid and standard OIDC tokens are supported.
237+
In case of standard tokens, the value of the `sub` field will be used to match the assignee in the policies.
238+
239+
#### Customizing OIDC verification
240+
241+
Several configuration options can be added to further restrict authentication when using OIDC tokens,
242+
by adding entries to the Components.js configuration of the UMA server.
243+
All options of the [verification function](https://github.com/panva/jose/blob/main/docs/jwt/verify/interfaces/JWTVerifyOptions.md)
244+
can be added.
245+
For example, the max age of a token can be set to 60s by adding the following block:
246+
```json
247+
{
248+
"@id": "urn:uma:default:OidcVerifier",
249+
"verifyOptions": [
250+
{
251+
"OidcVerifier:_verifyOptions_key": "maxTokenAge",
252+
"OidcVerifier:_verifyOptions_value": 60
253+
}
254+
]
255+
}
256+
```
257+
Other options can be added in a similar fashion by adding entries to the above array.
258+
259+
It is also possible to restrict which token issuers are allowed.
260+
This can be done by adding the following configuration:
261+
```json
262+
{
263+
"@id": "urn:uma:default:OidcVerifier",
264+
"allowedIssuers": [
265+
"http://example.com/idp/",
266+
"http://example.org/issuer/"
267+
]
268+
}
269+
```
237270

238271
### Generate token
239272

packages/uma/config/credentials/verifiers/default.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@
1717
{
1818
"TypedVerifier:_verifiers_key": "http://openid.net/specs/openid-connect-core-1_0.html#IDToken",
1919
"TypedVerifier:_verifiers_value": {
20-
"@id": "urn:uma:default:SolidOidcVerifier",
21-
"@type": "SolidOidcVerifier"
20+
"@id": "urn:uma:default:OidcVerifier",
21+
"@type": "OidcVerifier",
22+
"baseUrl": { "@id": "urn:uma:variables:baseUrl" }
2223
}
2324
},
2425
{
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { createSolidTokenVerifier } from '@solid/access-token-verifier';
2+
import { BadRequestHttpError } from '@solid/community-server';
3+
import { getLoggerFor } from 'global-logger-factory';
4+
import { createRemoteJWKSet, decodeJwt, JWTPayload, jwtVerify, JWTVerifyOptions } from 'jose';
5+
import { CLIENTID, WEBID } from '../Claims';
6+
import { ClaimSet } from '../ClaimSet';
7+
import { Credential } from '../Credential';
8+
import { OIDC } from '../Formats';
9+
import { Verifier } from './Verifier';
10+
11+
/**
12+
* A Verifier for OIDC ID Tokens.
13+
*
14+
* The `allowedIssuers` list can be used to only allow tokens from these issuers.
15+
* Default is an empty list, which allows all issuers.
16+
*/
17+
export class OidcVerifier implements Verifier {
18+
protected readonly logger = getLoggerFor(this);
19+
20+
private readonly verifyToken = createSolidTokenVerifier();
21+
22+
public constructor(
23+
protected readonly baseUrl: string,
24+
protected readonly allowedIssuers: string[] = [],
25+
protected readonly verifyOptions: Record<string, unknown> = {},
26+
) {}
27+
28+
/** @inheritdoc */
29+
public async verify(credential: Credential): Promise<ClaimSet> {
30+
this.logger.debug(`Verifying credential ${JSON.stringify(credential)}`);
31+
if (credential.format !== OIDC) {
32+
throw new BadRequestHttpError(`Token format ${credential.format} does not match this processor's format.`);
33+
}
34+
35+
// We first need to determine if this is a Solid OIDC token or a standard one
36+
const unsafeDecoded = decodeJwt(credential.token);
37+
const isSolidTOken = Array.isArray(unsafeDecoded.aud) && unsafeDecoded.aud.includes('solid');
38+
39+
try {
40+
this.validateToken(unsafeDecoded);
41+
if (isSolidTOken) {
42+
return await this.verifySolidToken(credential.token);
43+
} else {
44+
return await this.verifyStandardToken(credential.token, unsafeDecoded.iss!);
45+
}
46+
} catch (error: unknown) {
47+
const message = `Error verifying OIDC ID Token: ${(error as Error).message}`;
48+
49+
this.logger.debug(message);
50+
throw new BadRequestHttpError(message);
51+
}
52+
}
53+
54+
protected validateToken(payload: JWTPayload): void {
55+
if (payload.aud !== this.baseUrl && !(Array.isArray(payload.aud) && payload.aud.includes(this.baseUrl))) {
56+
throw new BadRequestHttpError('This server is not valid audience for the token');
57+
}
58+
if (!payload.iss || this.allowedIssuers.length > 0 && !this.allowedIssuers.includes(payload.iss)) {
59+
throw new BadRequestHttpError('Unsupported issuer');
60+
}
61+
}
62+
63+
protected async verifySolidToken(token: string): Promise<{ [WEBID]: string, [CLIENTID]?: string }> {
64+
const claims = await this.verifyToken(`Bearer ${token}`);
65+
66+
this.logger.info(`Authenticated via a Solid OIDC. ${JSON.stringify(claims)}`);
67+
68+
return ({
69+
// TODO: would have to use different value than "WEBID"
70+
// TODO: still want to use WEBID as external value potentially?
71+
[WEBID]: claims.webid,
72+
...claims.client_id && { [CLIENTID]: claims.client_id }
73+
});
74+
}
75+
76+
protected async verifyStandardToken(token: string, issuer: string):
77+
Promise<{ [WEBID]: string, [CLIENTID]?: string }> {
78+
const jwkSet = createRemoteJWKSet(new URL(issuer));
79+
const decoded = await jwtVerify(token, jwkSet, this.verifyOptions);
80+
if (!decoded.payload.sub) {
81+
throw new BadRequestHttpError('Invalid OIDC token: missing `sub` claim');
82+
}
83+
const client = decoded.payload.azp as string | undefined;
84+
return ({
85+
[WEBID]: decoded.payload.sub,
86+
...client && { [CLIENTID]: client }
87+
});
88+
}
89+
}

packages/uma/src/credentials/verify/SolidOidcVerifier.ts

Lines changed: 0 additions & 42 deletions
This file was deleted.

packages/uma/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export * from './credentials/Credential';
88
export * from './credentials/verify/Verifier';
99
export * from './credentials/verify/TypedVerifier';
1010
export * from './credentials/verify/UnsecureVerifier';
11-
export * from './credentials/verify/SolidOidcVerifier';
11+
export * from './credentials/verify/OidcVerifier';
1212
export * from './credentials/verify/JwtVerifier';
1313

1414
// Dialog
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import * as accessTokenVerifier from '@solid/access-token-verifier';
2+
import { JWTPayload } from 'jose';
3+
import * as jose from 'jose';
4+
import { MockInstance } from 'vitest';
5+
import { Credential } from '../../../../src/credentials/Credential';
6+
import { OidcVerifier } from '../../../../src/credentials/verify/OidcVerifier';
7+
8+
vi.mock('jose', () => ({
9+
createRemoteJWKSet: vi.fn(),
10+
decodeJwt: vi.fn(),
11+
jwtVerify: vi.fn(),
12+
}));
13+
14+
describe('OidcVerifier', (): void => {
15+
const issuer = 'http://example.org/issuer';
16+
const baseUrl = 'http://example.com/uma';
17+
const credential: Credential = {
18+
format: 'http://openid.net/specs/openid-connect-core-1_0.html#IDToken',
19+
token: 'token',
20+
};
21+
22+
const decodedToken: JWTPayload = {
23+
sub: 'sub',
24+
iss: issuer,
25+
aud: baseUrl,
26+
};
27+
const remoteKeySet = 'remoteKeySet';
28+
const decodeJwt = vi.spyOn(jose, 'decodeJwt');
29+
const jwtVerify = vi.spyOn(jose, 'jwtVerify');
30+
const createRemoteJWKSet = vi.spyOn(jose, 'createRemoteJWKSet');
31+
const verifierMock = vi.fn();
32+
vi.spyOn(accessTokenVerifier, 'createSolidTokenVerifier').mockReturnValue(verifierMock);
33+
let verifier: OidcVerifier;
34+
35+
beforeEach(async(): Promise<void> => {
36+
vi.clearAllMocks();
37+
decodeJwt.mockReturnValue(decodedToken);
38+
jwtVerify.mockResolvedValue({ payload: decodedToken } as any);
39+
createRemoteJWKSet.mockReturnValue(remoteKeySet as any);
40+
41+
verifierMock.mockResolvedValue({
42+
webid: 'webId',
43+
client_id: 'clientId'
44+
});
45+
46+
verifier = new OidcVerifier(baseUrl)
47+
});
48+
49+
it('errors on non-OIDC credentials.', async(): Promise<void> => {
50+
await expect(verifier.verify({ format: 'wrong', token: 'token' })).rejects
51+
.toThrow("Token format wrong does not match this processor's format.");
52+
});
53+
54+
it('errors if the server is not part of the audience.', async(): Promise<void> => {
55+
decodeJwt.mockReturnValue({ ...decodedToken, aud: 'wrong' });
56+
await expect(verifier.verify(credential)).rejects.toThrow('This server is not valid audience for the token');
57+
58+
decodeJwt.mockReturnValue({ ...decodedToken, aud: undefined });
59+
await expect(verifier.verify(credential)).rejects.toThrow('This server is not valid audience for the token');
60+
});
61+
62+
it('errors if the issuer is not allowed.', async(): Promise<void> => {
63+
verifier = new OidcVerifier(baseUrl, [ 'otherIssuer' ]);
64+
await expect(verifier.verify(credential)).rejects.toThrow('Unsupported issuer');
65+
66+
verifier = new OidcVerifier(baseUrl, [ issuer ]);
67+
await expect(verifier.verify(credential)).resolves.toEqual({
68+
['urn:solidlab:uma:claims:types:webid']: 'sub',
69+
});
70+
});
71+
72+
describe('parsing a Solid OIDC token', (): void => {
73+
beforeEach(async(): Promise<void> => {
74+
decodeJwt.mockReturnValue({ ...decodedToken, aud: [ baseUrl, 'solid' ] });
75+
});
76+
77+
it('returns the extracted WebID.', async(): Promise<void> => {
78+
await expect(verifier.verify(credential)).resolves.toEqual({
79+
['urn:solidlab:uma:claims:types:webid']: 'webId',
80+
['urn:solidlab:uma:claims:types:clientid']: 'clientId',
81+
});
82+
});
83+
84+
it('throws an error if the token could not be verified.', async(): Promise<void> => {
85+
verifierMock.mockRejectedValueOnce(new Error('bad data'));
86+
await expect(verifier.verify(credential)).rejects.toThrow('Error verifying OIDC ID Token: bad data');
87+
});
88+
});
89+
90+
describe('parsing a standard OIDC token', (): void => {
91+
it('errors if the sub claim is missing', async(): Promise<void> => {
92+
jwtVerify.mockResolvedValue({ payload: { ...decodedToken, sub: undefined } } as any);
93+
await expect(verifier.verify(credential)).rejects.toThrow('Invalid OIDC token: missing `sub` claim');
94+
});
95+
96+
it('returns the extracted identity.', async(): Promise<void> => {
97+
await expect(verifier.verify(credential)).resolves.toEqual({
98+
['urn:solidlab:uma:claims:types:webid']: 'sub',
99+
});
100+
});
101+
102+
it('returns the extracted client identifier.', async(): Promise<void> => {
103+
jwtVerify.mockResolvedValue({ payload: { ...decodedToken, azp: 'client' } } as any);
104+
105+
await expect(verifier.verify(credential)).resolves.toEqual({
106+
['urn:solidlab:uma:claims:types:webid']: 'sub',
107+
['urn:solidlab:uma:claims:types:clientid']: 'client',
108+
});
109+
});
110+
});
111+
});

packages/uma/test/unit/credentials/verify/SolidOidcVerifier.test.ts

Lines changed: 0 additions & 45 deletions
This file was deleted.

0 commit comments

Comments
 (0)