Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(client,js)!: support custom jwt verifier #622

Merged
merged 4 commits into from
Feb 19, 2024
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
7 changes: 7 additions & 0 deletions .changeset/giant-lamps-sniff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@logto/client": minor
---

support custom jwt verifier

Now it's possible to pass a `JwtVerifier` instance to the Logto client adapter to verify the JWT token. The client also has a built-in verifier that keeps the same behavior as before.
7 changes: 7 additions & 0 deletions .changeset/lucky-schools-doubt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@logto/js": patch
---

use `.toString()` for `URLSearchParams` passing to requester

Some `fetch` implementation doesn't support `URLSearchParams` directly, so we need to convert it to string before passing it to the requester.
8 changes: 8 additions & 0 deletions .changeset/orange-lamps-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@logto/client": patch
---

refactor adapter types

- `generateState()`, `generateCodeVerifier()`, `generateCodeChallenge()` now accept both Promise and non-Promise return types.
- the navigate function now calls with a second parameter which has the state information. (`{ redirectUri?: string; for: 'sign-in' | 'sign-out' }`)
7 changes: 7 additions & 0 deletions .changeset/silver-apricots-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@logto/js": major
---

remove `verifyIdToken()` util function, now it's in `@logto/client`

This change removes the dependency of `jose` which keeps the package clean.
15 changes: 15 additions & 0 deletions .changeset/stupid-zoos-lay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@logto/client": minor
---

provide a shim version without importing `jose` (`@logto/client/shim`)

It can avoid the use of `jose` package which is useful for certain environments that don't support native modules like `crypto`. (e.g. React Native)

To use the shim client:

```ts
import { StandardLogtoClient } from '@logto/client/shim';
```

The `StandardLogtoClient` class is identical to the original `LogtoClient` class, except it doesn't have the default JWT verifier implemented.
9 changes: 9 additions & 0 deletions .changeset/thirty-gifts-suffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@logto/client": minor
"@logto/js": minor
---

update prompt usage to allow multiple values

- Logto config supports both `Prompt` and `Prompt[]` types now.
- Added `Prompt.None` enum value.
2 changes: 1 addition & 1 deletion packages/capacitor/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe('CapacitorLogtoClient', () => {
it('should override navigate', async () => {
const client = createClient();
expect(client.getAdapter().navigate).toBeDefined();
await client.getAdapter().navigate('https://example.com');
await client.getAdapter().navigate('https://example.com', { for: 'sign-in' });

const spy = jest.spyOn(Browser, 'open');
expect(spy).toHaveBeenCalledWith({
Expand Down
9 changes: 9 additions & 0 deletions packages/client/jest.setup.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
// Need to disable following rules to mock text-decode/text-encoder and crypto for jsdom
// https://github.com/jsdom/jsdom/issues/1612

import crypto from 'node:crypto';

import { TextDecoder, TextEncoder } from 'text-encoder';

/* eslint-disable @silverhand/fp/no-mutation */
// Mock WebCrypto in JSDOM
if (global.window !== undefined) {
global.CryptoKey = crypto.webcrypto.CryptoKey;
global.crypto.subtle = crypto.webcrypto.subtle;
}

global.TextDecoder = TextDecoder;
global.TextEncoder = TextEncoder;
/* eslint-enable @silverhand/fp/no-mutation */
16 changes: 12 additions & 4 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,18 @@
"module": "./lib/index.js",
"types": "./lib/index.d.ts",
"exports": {
"types": "./lib/index.d.ts",
"require": "./lib/index.cjs",
"import": "./lib/index.js",
"default": "./lib/index.js"
".": {
"types": "./lib/index.d.ts",
"require": "./lib/index.cjs",
"import": "./lib/index.js",
"default": "./lib/index.js"
},
"./shim": {
"types": "./lib/shim.d.ts",
"require": "./lib/shim.cjs",
"import": "./lib/shim.js",
"default": "./lib/shim.js"
}
},
"files": [
"lib"
Expand Down
12 changes: 11 additions & 1 deletion packages/client/rollup.config.js
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
export { default } from '../../rollup.config.js';
import defaultConfig from '../../rollup.config.js';

/**
* @type {import('rollup').RollupOptions}
*/
const config = {
...defaultConfig,
input: ['src/index.ts', 'src/shim.ts'],
};

export default config;
178 changes: 178 additions & 0 deletions packages/client/src/adapter/defaults.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { LogtoError } from '@logto/js';
import { createRemoteJWKSet, exportJWK, generateKeyPair, SignJWT } from 'jose';
import nock from 'nock';

import { verifyIdToken } from './defaults.js';

const createDefaultJwks = () => createRemoteJWKSet(new URL('https://logto.dev/oidc/jwks'));

const mockJwkResponse = (key: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (global.window === undefined) {
// Mock in Node env
nock('https://logto.dev', { allowUnmocked: true })
.get('/oidc/jwks')
.reply(200, { keys: [key] });
} else {
// Mock in JSDOM env
// @ts-expect-error for testing
// eslint-disable-next-line @silverhand/fp/no-mutation
global.fetch = jest.fn(async () => ({
status: 200,
json: async () => ({ keys: [key] }),
}));
}
};

describe('verifyIdToken', () => {
test('valid ID Token, signed by RS256 algorithm, should not throw', async () => {
const alg = 'RS256';
const { privateKey, publicKey } = await generateKeyPair(alg);

mockJwkResponse(await exportJWK(publicKey));

const idToken = await new SignJWT({})
.setProtectedHeader({ alg })
.setIssuer('foo')
.setSubject('bar')
.setAudience('qux')
.setExpirationTime('2h')
.setIssuedAt()
.sign(privateKey);

const jwks = createDefaultJwks();

await expect(verifyIdToken(idToken, 'qux', 'foo', jwks)).resolves.not.toThrow();
});

test('valid ID Token, signed by ES512 algorithm, should not throw', async () => {
const alg = 'ES512';
const { privateKey, publicKey } = await generateKeyPair(alg);

mockJwkResponse(await exportJWK(publicKey));

const idToken = await new SignJWT({})
.setProtectedHeader({ alg })
.setIssuer('foo')
.setSubject('bar')
.setAudience('qux')
.setExpirationTime('2h')
.setIssuedAt()
.sign(privateKey);

const jwks = createDefaultJwks();

await expect(verifyIdToken(idToken, 'qux', 'foo', jwks)).resolves.not.toThrow();
});

test('mismatched signature should throw', async () => {
const alg = 'RS256';
const { privateKey } = await generateKeyPair(alg);
const { publicKey } = await generateKeyPair(alg);

mockJwkResponse(await exportJWK(publicKey));

const idToken = await new SignJWT({})
.setProtectedHeader({ alg })
.setIssuer('foo')
.setSubject('bar')
.setAudience('foz')
.setExpirationTime('2h')
.setIssuedAt()
.sign(privateKey);

const jwks = createDefaultJwks();

await expect(verifyIdToken(idToken, 'foo', 'baz', jwks)).rejects.toThrowError(
'signature verification failed'
);
});

test('mismatched issuer should throw', async () => {
const alg = 'RS256';
const { privateKey, publicKey } = await generateKeyPair(alg);

mockJwkResponse(await exportJWK(publicKey));

const idToken = await new SignJWT({})
.setProtectedHeader({ alg })
.setIssuer('foo')
.setSubject('bar')
.setAudience('qux')
.setExpirationTime('2h')
.setIssuedAt()
.sign(privateKey);

const jwks = createDefaultJwks();

await expect(verifyIdToken(idToken, 'qux', 'xxx', jwks)).rejects.toThrowError(
'unexpected "iss" claim value'
);
});

test('mismatched audience should throw', async () => {
const alg = 'RS256';
const { privateKey, publicKey } = await generateKeyPair(alg);

mockJwkResponse(await exportJWK(publicKey));

const idToken = await new SignJWT({})
.setProtectedHeader({ alg })
.setIssuer('foo')
.setSubject('bar')
.setAudience('qux')
.setExpirationTime('2h')
.setIssuedAt()
.sign(privateKey);

const jwks = createDefaultJwks();

await expect(verifyIdToken(idToken, 'xxx', 'foo', jwks)).rejects.toThrowError(
'unexpected "aud" claim value'
);
});

test('expired ID Token should throw', async () => {
const alg = 'RS256';
const { privateKey, publicKey } = await generateKeyPair(alg);

mockJwkResponse(await exportJWK(publicKey));

const idToken = await new SignJWT({})
.setProtectedHeader({ alg })
.setIssuer('foo')
.setSubject('bar')
.setAudience('qux')
.setExpirationTime(Date.now() / 1000 - 1)
.setIssuedAt()
.sign(privateKey);

const jwks = createDefaultJwks();

await expect(verifyIdToken(idToken, 'qux', 'foo', jwks)).rejects.toThrowError(
'"exp" claim timestamp check failed'
);
});

test('issued at time, too far away from current time, should throw', async () => {
const alg = 'RS256';
const { privateKey, publicKey } = await generateKeyPair(alg);

mockJwkResponse(await exportJWK(publicKey));

const idToken = await new SignJWT({})
.setProtectedHeader({ alg })
.setIssuer('foo')
.setSubject('bar')
.setAudience('qux')
.setExpirationTime('2h')
.setIssuedAt(Date.now() / 1000 - 301)
.sign(privateKey);

const jwks = createDefaultJwks();

await expect(verifyIdToken(idToken, 'qux', 'foo', jwks)).rejects.toMatchError(
new LogtoError('id_token.invalid_iat')
);
});
});
39 changes: 39 additions & 0 deletions packages/client/src/adapter/defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { LogtoError } from '@logto/js';
import type { JWTVerifyGetKey } from 'jose';
import { jwtVerify, createRemoteJWKSet } from 'jose';

import { type StandardLogtoClient } from '../client.js';

import { type JwtVerifier } from './types.js';

const issuedAtTimeTolerance = 300; // 5 minutes

export const verifyIdToken = async (
idToken: string,
clientId: string,
issuer: string,
jwks: JWTVerifyGetKey
) => {
const result = await jwtVerify(idToken, jwks, { audience: clientId, issuer });

if (Math.abs((result.payload.iat ?? 0) - Date.now() / 1000) > issuedAtTimeTolerance) {
throw new LogtoError('id_token.invalid_iat');
}
};

export class DefaultJwtVerifier implements JwtVerifier {
protected getJwtVerifyGetKey?: JWTVerifyGetKey;

constructor(protected client: StandardLogtoClient) {}

async verifyIdToken(idToken: string): Promise<void> {
const { appId } = this.client.logtoConfig;
const { issuer, jwksUri } = await this.client.getOidcConfig();

if (!this.getJwtVerifyGetKey) {
this.getJwtVerifyGetKey = createRemoteJWKSet(new URL(jwksUri));
}

await verifyIdToken(idToken, appId, issuer, this.getJwtVerifyGetKey);
}
}
6 changes: 3 additions & 3 deletions packages/client/src/adapter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ export class ClientAdapterInstance implements ClientAdapter {
storage!: Storage<StorageKey | PersistKey>;
unstable_cache?: Storage<CacheKey> | undefined;
navigate!: Navigate;
generateState!: () => string;
generateCodeVerifier!: () => string;
generateCodeChallenge!: (codeVerifier: string) => Promise<string>;
generateState!: () => string | Promise<string>;
generateCodeVerifier!: () => string | Promise<string>;
generateCodeChallenge!: (codeVerifier: string) => string | Promise<string>;
/* END OF IMPLEMENTATION */

constructor(adapter: ClientAdapter) {
Expand Down
Loading
Loading