feat: Sync to OSS repo

This commit is contained in:
Wanjohi
2026-08-06 22:13:51 +03:00
parent 46d2a56180
commit 3faac3008f
144 changed files with 27561 additions and 0 deletions

View File

@@ -0,0 +1,149 @@
import {
expect,
test,
setSystemTime,
describe,
beforeEach,
afterEach,
spyOn,
afterAll,
mock
} from 'bun:test';
import { object, string } from 'valibot';
import { createClient } from '../src/client.js';
import { InvalidAccessTokenError, InvalidRefreshTokenError } from '../src/error.js';
import { issuer } from '../src/issuer.js';
import { MemoryStorage } from '../src/storage/memory.js';
import { createSubjects } from '../src/subject.js';
const subjects = createSubjects({
user: object({
userID: string()
})
});
let storage = MemoryStorage();
const auth = issuer({
storage,
subjects,
allow: async () => true,
success: async (ctx) => {
return ctx.subject('user', {
userID: '123'
});
},
ttl: {
access: 60
},
providers: {
dummy: {
type: 'dummy',
init(route, ctx) {
route.get('/authorize', async (c) => {
return ctx.success(c, {
email: 'foo@bar.com'
});
});
}
}
}
});
const expectNonEmptyString = expect.stringMatching(/.+/);
beforeEach(async () => {
setSystemTime(new Date('1/1/2024'));
});
afterEach(() => {
setSystemTime();
});
const consoleSpy = spyOn(console, 'error').mockImplementation(mock());
afterAll(() => {
consoleSpy.mockRestore();
});
describe('verify', () => {
let tokens: { access: string; refresh: string };
let client: ReturnType<typeof createClient>;
beforeEach(async () => {
client = createClient({
// use different issuer per test file to avoid JWKS cache issues
issuer: 'https://auth1.example.com',
clientID: '123',
fetch: (a, b) => Promise.resolve(auth.request(a, b))
});
const [verifier, authorization] = await client.pkce('https://client.example.com/callback');
let response = await auth.request(authorization);
response = await auth.request(response.headers.get('location')!, {
headers: {
cookie: response.headers.get('set-cookie')!
}
});
const location = new URL(response.headers.get('location')!);
const code = location.searchParams.get('code');
const exchanged = await client.exchange(code!, 'https://client.example.com/callback', verifier);
if (exchanged.err) throw exchanged.err;
tokens = exchanged.tokens;
});
test('success', async () => {
const refreshSpy = spyOn(client, 'refresh');
const verified = await client.verify(subjects, tokens.access);
expect(verified).toStrictEqual({
aud: '123',
subject: {
type: 'user',
properties: {
userID: '123'
}
}
});
expect(refreshSpy).not.toBeCalled();
});
test('success after refresh', async () => {
const refreshSpy = spyOn(client, 'refresh');
setSystemTime(Date.now() + 1000 * 6000 + 1000);
const verified = await client.verify(subjects, tokens.access, {
refresh: tokens.refresh
});
expect(verified).toStrictEqual({
aud: '123',
tokens: {
expiresIn: 60,
access: expectNonEmptyString,
refresh: expectNonEmptyString
},
subject: {
type: 'user',
properties: {
userID: '123'
}
}
});
expect(refreshSpy).toBeCalled();
});
test('failure with expired access token', async () => {
setSystemTime(Date.now() + 1000 * 6000 + 1000);
const verified = await client.verify(subjects, tokens.access);
expect(verified).toStrictEqual({
err: expect.any(InvalidAccessTokenError)
});
});
test('failure with invalid refresh token', async () => {
setSystemTime(Date.now() + 1000 * 6000 + 1000);
const verified = await client.verify(subjects, tokens.access, {
refresh: 'foo'
});
expect(verified).toStrictEqual({
err: expect.any(InvalidRefreshTokenError)
});
});
});

View File

@@ -0,0 +1,385 @@
import { expect, test, setSystemTime, describe, beforeEach, afterEach } from 'bun:test';
import { object, string } from 'valibot';
import { createClient } from '../src/client.js';
import { issuer } from '../src/issuer.js';
import { Provider } from '../src/provider/provider.js';
import { MemoryStorage } from '../src/storage/memory.js';
import { createSubjects } from '../src/subject.js';
const subjects = createSubjects({
user: object({
userID: string()
})
});
let storage = MemoryStorage();
const issuerConfig = {
storage,
subjects,
allow: async () => true,
ttl: {
access: 60,
refresh: 6000,
refreshReuse: 60,
refreshRetention: 6000
},
providers: {
dummy: {
type: 'dummy',
init(route, ctx) {
route.get('/authorize', async (c) => {
return ctx.success(c, {
email: 'foo@bar.com'
});
});
},
client: async ({ clientID, clientSecret }) => {
if (clientID !== 'myuser' && clientSecret !== 'mypass') {
throw new Error('Wrong credentials');
}
return {
email: 'foo@bar.com'
};
}
} satisfies Provider<{ email: string }>
},
success: async (ctx, value) => {
if (value.provider === 'dummy') {
return ctx.subject('user', {
userID: '123'
});
}
throw new Error('Invalid provider: ' + value.provider);
}
};
const auth = issuer(issuerConfig);
const expectNonEmptyString = expect.stringMatching(/.+/);
beforeEach(async () => {
setSystemTime(new Date('1/1/2024'));
});
afterEach(() => {
setSystemTime();
});
describe('code flow', () => {
test('success', async () => {
const client = createClient({
issuer: 'https://auth.example.com',
clientID: '123',
fetch: (a, b) => Promise.resolve(auth.request(a, b))
});
const { challenge, url } = await client.authorize(
'https://client.example.com/callback',
'code',
{
pkce: true
}
);
let response = await auth.request(url);
expect(response.status).toBe(302);
response = await auth.request(response.headers.get('location')!, {
headers: {
cookie: response.headers.get('set-cookie')!
}
});
expect(response.status).toBe(302);
const location = new URL(response.headers.get('location')!);
const code = location.searchParams.get('code');
expect(code).not.toBeNull();
const exchanged = await client.exchange(
code!,
'https://client.example.com/callback',
challenge.verifier
);
if (exchanged.err) throw exchanged.err;
const tokens = exchanged.tokens;
expect(tokens).toStrictEqual({
access: expectNonEmptyString,
refresh: expectNonEmptyString,
expiresIn: 60
});
const verified = await client.verify(subjects, tokens.access);
if (verified.err) throw verified.err;
expect(verified.subject).toStrictEqual({
type: 'user',
properties: {
userID: '123'
}
});
});
});
describe('client credentials flow', () => {
test('success', async () => {
const client = createClient({
issuer: 'https://auth.example.com',
clientID: '123',
fetch: (a, b) => Promise.resolve(auth.request(a, b))
});
const response = await auth.request('https://auth.example.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'client_credentials',
provider: 'dummy',
client_id: 'myuser',
client_secret: 'mypass'
}).toString()
});
expect(response.status).toBe(200);
const tokens = await response.json();
expect(tokens).toStrictEqual({
access_token: expectNonEmptyString,
refresh_token: expectNonEmptyString
});
const verified = await client.verify(subjects, tokens.access_token);
expect(verified).toStrictEqual({
aud: 'myuser',
subject: {
type: 'user',
properties: {
userID: '123'
}
}
});
});
});
describe('refresh token', () => {
let tokens: { access: string; refresh: string };
let client: ReturnType<typeof createClient>;
const generateTokens = async (issuer: typeof auth) => {
const { challenge, url } = await client.authorize(
'https://client.example.com/callback',
'code',
{
pkce: true
}
);
let response = await issuer.request(url);
response = await issuer.request(response.headers.get('location')!, {
headers: {
cookie: response.headers.get('set-cookie')!
}
});
const location = new URL(response.headers.get('location')!);
const code = location.searchParams.get('code');
const exchanged = await client.exchange(
code!,
'https://client.example.com/callback',
challenge.verifier
);
if (exchanged.err) throw exchanged.err;
return exchanged.tokens;
};
const createClientAndTokens = async (issuer: typeof auth) => {
client = createClient({
issuer: 'https://auth.example.com',
clientID: '123',
fetch: (a, b) => Promise.resolve(issuer.request(a, b))
});
tokens = await generateTokens(issuer);
};
const requestRefreshToken = async (refresh_token: string, issuer?: typeof auth) =>
(issuer ?? auth).request('https://auth.example.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'refresh_token',
...(refresh_token ? { refresh_token } : {})
}).toString()
});
beforeEach(async () => {
await createClientAndTokens(auth);
});
test('success', async () => {
setSystemTime(Date.now() + 1000 * 60 + 1000);
let response = await requestRefreshToken(tokens.refresh);
expect(response.status).toBe(200);
const refreshed = await response.json();
expect(refreshed).toStrictEqual({
access_token: expectNonEmptyString,
refresh_token: expectNonEmptyString,
expires_in: expect.any(Number)
});
expect(refreshed.access_token).not.toEqual(tokens.access);
expect(refreshed.refresh_token).not.toEqual(tokens.refresh);
const verified = await client.verify(subjects, refreshed.access_token);
expect(verified).toStrictEqual({
aud: '123',
subject: {
type: 'user',
properties: {
userID: '123'
}
}
});
});
test('success with valid access token', async () => {
// have to increment the time so new access token claims are different (i.e. exp)
setSystemTime(Date.now() + 1000);
let response = await requestRefreshToken(tokens.refresh);
expect(response.status).toBe(200);
const refreshed = await response.json();
expect(refreshed).toStrictEqual({
access_token: expectNonEmptyString,
refresh_token: expectNonEmptyString,
expires_in: expect.any(Number)
});
expect(refreshed.access_token).not.toEqual(tokens.access);
expect(refreshed.refresh_token).not.toEqual(tokens.refresh);
const verified = await client.verify(subjects, refreshed.access_token);
expect(verified).toStrictEqual({
aud: '123',
subject: {
type: 'user',
properties: {
userID: '123'
}
}
});
});
test('multiple active tokens', async () => {
const tokens2 = await generateTokens(auth);
let response = await requestRefreshToken(tokens.refresh);
expect(response.status).toBe(200);
response = await requestRefreshToken(tokens2.refresh);
expect(response.status).toBe(200);
});
test('failure with reuse interval disabled', async () => {
const issuerWithoutReuse = issuer({
...issuerConfig,
ttl: {
...issuerConfig.ttl,
reuse: 0,
retention: 0
}
});
await createClientAndTokens(issuerWithoutReuse);
let response = await requestRefreshToken(tokens.refresh, issuerWithoutReuse);
expect(response.status).toBe(200);
response = await requestRefreshToken(tokens.refresh, issuerWithoutReuse);
expect(response.status).toBe(400);
const reused = await response.json();
expect(reused.error).toBe('invalid_grant');
});
test('success with reuse interval enabled', async () => {
let response = await requestRefreshToken(tokens.refresh);
expect(response.status).toBe(200);
const refreshed = await response.json();
const [, refreshedAccessPayload] = refreshed.access_token.split('.');
setSystemTime(Date.now() + 1000 * 30);
response = await requestRefreshToken(tokens.refresh);
expect(response.status).toBe(200);
const reused = await response.json();
const [, reusedAccessPayload] = reused.access_token.split('.');
expect(refreshed.refresh_token).toEqual(reused.refresh_token);
/**
* Access token signature is different every time for ES256 alg,
* but the payload should be the same.
*/
expect(refreshedAccessPayload).toEqual(reusedAccessPayload);
});
test('invalidated with reuse detection', async () => {
let response = await requestRefreshToken(tokens.refresh);
expect(response.status).toBe(200);
setSystemTime(Date.now() + 1000 * 60 + 1000);
response = await requestRefreshToken(tokens.refresh);
expect(response.status).toBe(400);
});
test('expired failure', async () => {
setSystemTime(Date.now() + 1000 * 6000 + 1000);
let response = await requestRefreshToken(tokens.refresh);
expect(response.status).toBe(400);
const reused = await response.json();
expect(reused.error).toBe('invalid_grant');
});
test('missing failure', async () => {
let response = await requestRefreshToken('');
expect(response.status).toBe(400);
const reused = await response.json();
expect(reused.error).toBe('invalid_request');
});
});
describe('user info', () => {
let tokens: { access: string; refresh: string };
let client: ReturnType<typeof createClient>;
const generateTokens = async (issuer: typeof auth) => {
const { challenge, url } = await client.authorize(
'https://client.example.com/callback',
'code',
{ pkce: true }
);
let response = await issuer.request(url);
response = await issuer.request(response.headers.get('location')!, {
headers: {
cookie: response.headers.get('set-cookie')!
}
});
const location = new URL(response.headers.get('location')!);
const code = location.searchParams.get('code');
const exchanged = await client.exchange(
code!,
'https://client.example.com/callback',
challenge.verifier
);
if (exchanged.err) throw exchanged.err;
return exchanged.tokens;
};
const createClientAndTokens = async (issuer: typeof auth) => {
client = createClient({
issuer: 'https://auth.example.com',
clientID: '123',
fetch: (a, b) => Promise.resolve(issuer.request(a, b))
});
tokens = await generateTokens(issuer);
};
beforeEach(async () => {
await createClientAndTokens(auth);
});
test('success', async () => {
const response = await auth.request('https://auth.example.com/userinfo', {
headers: { Authorization: `Bearer ${tokens.access}` }
});
const userinfo = await response.json();
expect(userinfo).toStrictEqual({ userID: '123' });
});
});

View File

@@ -0,0 +1,81 @@
import { expect, test } from 'bun:test';
import { object, string } from 'valibot';
import { createClient } from '../src/client.js';
import { issuer } from '../src/issuer.js';
import { MemoryStorage } from '../src/storage/memory.js';
import { createSubjects } from '../src/subject.js';
const subjects = createSubjects({
user: object({
userID: string()
})
});
const auth = issuer({
storage: MemoryStorage(),
subjects,
allow: async () => true,
success: async (ctx) => {
return ctx.subject('user', {
userID: '123'
});
},
ttl: {
access: 1
},
providers: {
dummy: {
type: 'dummy',
init(route, ctx) {
route.get('/authorize', async (c) => {
return ctx.success(c, {
email: 'foo@bar.com'
});
});
}
}
}
});
test('code flow', async () => {
const client = createClient({
issuer: 'https://auth.example.com',
clientID: '123',
fetch: (a, b) => Promise.resolve(auth.request(a, b))
});
const [verifier, authorization] = await client.pkce('https://client.example.com/callback');
let response = await auth.request(authorization);
expect(response.status).toBe(302);
response = await auth.request(response.headers.get('location')!, {
headers: {
cookie: response.headers.get('set-cookie')!
}
});
expect(response.status).toBe(302);
const location = new URL(response.headers.get('location')!);
const code = location.searchParams.get('code');
expect(code).not.toBeNull();
const exchanged = await client.exchange(code!, 'https://client.example.com/callback', verifier);
if (exchanged.err) throw exchanged.err;
expect(exchanged.tokens.access).toBeTruthy();
expect(exchanged.tokens.refresh).toBeTruthy();
const verified = await client.verify(subjects, exchanged.tokens.access);
if (verified.err) throw verified.err;
expect(verified.subject.type).toBe('user');
if (verified.subject.type !== 'user') throw new Error('Invalid subject');
expect(verified.subject.properties.userID).toBe('123');
await new Promise((resolve) => setTimeout(resolve, 2000));
const failed = await client.verify(subjects, exchanged.tokens.access);
expect(failed.err).toBeInstanceOf(Error);
const next = await client.verify(subjects, exchanged.tokens.access, {
refresh: exchanged.tokens.refresh
});
if (next.err) throw next.err;
expect(next.tokens?.access).toBeDefined();
expect(next.tokens?.refresh).toBeDefined();
expect(next.tokens?.access).not.toEqual(exchanged.tokens.access);
expect(next.tokens?.refresh).not.toEqual(exchanged.tokens.refresh);
await client.verify(subjects, next.tokens!.access!);
});

View File

@@ -0,0 +1,91 @@
import { afterEach, setSystemTime } from 'bun:test';
import { beforeEach, describe, expect, test } from 'bun:test';
import { MemoryStorage } from '../src/storage/memory.js';
let storage = MemoryStorage();
beforeEach(async () => {
storage = MemoryStorage();
setSystemTime(new Date('1/1/2024'));
});
afterEach(() => {
setSystemTime();
});
describe('set', () => {
test('basic', async () => {
await storage.set(['users', '123'], { name: 'Test User' });
const result = await storage.get(['users', '123']);
expect(result).toEqual({ name: 'Test User' });
});
test('ttl', async () => {
await storage.set(['temp', 'key'], { value: 'value' }, new Date(Date.now() + 100)); // 100ms TTL
let result = await storage.get(['temp', 'key']);
expect(result?.value).toBe('value');
setSystemTime(Date.now() + 150);
result = await storage.get(['temp', 'key']);
expect(result).toBeUndefined();
});
test('nested', async () => {
const complexObj = {
id: 1,
nested: { a: 1, b: { c: 2 } },
array: [1, 2, 3]
};
await storage.set(['complex'], complexObj);
const result = await storage.get(['complex']);
expect(result).toEqual(complexObj);
});
});
describe('get', () => {
test('missing', async () => {
const result = await storage.get(['nonexistent']);
expect(result).toBeUndefined();
});
test('key', async () => {
await storage.set(['a', 'b', 'c'], { value: 'nested' });
const result = await storage.get(['a', 'b', 'c']);
expect(result?.value).toBe('nested');
});
});
describe('remove', () => {
test('existing', async () => {
await storage.set(['test'], 'value');
await storage.remove(['test']);
const result = await storage.get(['test']);
expect(result).toBeUndefined();
});
test('missing', async () => {
expect(storage.remove(['nonexistent'])).resolves.toBeUndefined();
});
});
describe('scan', () => {
test('all', async () => {
await storage.set(['users', '1'], { id: 1 });
await storage.set(['users', '2'], { id: 2 });
await storage.set(['other'], { id: 3 });
const results = await Array.fromAsync(storage.scan(['users']));
expect(results).toHaveLength(2);
expect(results).toContainEqual([['users', '1'], { id: 1 }]);
expect(results).toContainEqual([['users', '2'], { id: 2 }]);
});
test('ttl', async () => {
await storage.set(['temp', '1'], 'a', new Date(Date.now() + 100));
await storage.set(['temp', '2'], 'b', new Date(Date.now() + 100));
await storage.set(['temp', '3'], 'c');
expect(await Array.fromAsync(storage.scan(['temp']))).toHaveLength(3);
setSystemTime(Date.now() + 150);
expect(await Array.fromAsync(storage.scan(['temp']))).toHaveLength(1);
});
});

View File

@@ -0,0 +1,100 @@
import { expect, test } from 'bun:test';
import { Context } from 'hono';
import { getRelativeUrl, isDomainMatch } from '../src/util.js';
test('isDomainMatch', () => {
// Basic matches
expect(isDomainMatch('example.com', 'example.com')).toBe(true);
expect(isDomainMatch('sub.example.com', 'example.com')).toBe(true);
expect(isDomainMatch('a.example.com', 'b.example.com')).toBe(true);
// Local hostnames
expect(isDomainMatch('romulus', 'romulus')).toBe(true);
expect(isDomainMatch('romulus', 'remus')).toBe(false);
expect(isDomainMatch('localhost', 'localhost')).toBe(true);
expect(isDomainMatch('server', 'server.local')).toBe(false);
// Two-part TLDs
expect(isDomainMatch('example.co.uk', 'example.co.uk')).toBe(true);
expect(isDomainMatch('sub.example.co.uk', 'example.co.uk')).toBe(true);
expect(isDomainMatch('evil.co.uk', 'bank.co.uk')).toBe(false);
expect(isDomainMatch('example.com.au', 'example.com.au')).toBe(true);
// Attack vectors
// Attempt to match on TLD only
expect(isDomainMatch('evil.com', 'bank.com')).toBe(false);
expect(isDomainMatch('evil.co.uk', 'bank.co.uk')).toBe(false);
// Subdomain attacks
expect(isDomainMatch('evil.com.attacker.com', 'evil.com')).toBe(false);
expect(isDomainMatch('bank.co.uk.attacker.com', 'bank.co.uk')).toBe(false);
expect(isDomainMatch('example.com.evil.com', 'example.com')).toBe(false);
// Prefix attacks
expect(isDomainMatch('myexample.com', 'example.com')).toBe(false);
expect(isDomainMatch('exampleevilsite.com', 'example.com')).toBe(false);
// Double-dot attacks
expect(isDomainMatch('example..com', 'example.com')).toBe(false);
expect(isDomainMatch('evil..co..uk', 'bank.co.uk')).toBe(false);
// Empty parts attacks
expect(isDomainMatch('example.com.', 'example.com')).toBe(false);
// Mixed case attacks
expect(isDomainMatch('EXAMPLE.COM', 'example.com')).toBe(false);
expect(isDomainMatch('Example.Co.Uk', 'example.co.uk')).toBe(false);
// IP address attempts
expect(isDomainMatch('127.0.0.1', 'localhost')).toBe(false);
expect(isDomainMatch('192.168.1.1', '192.168.1.1')).toBe(true);
// Special character attacks
expect(isDomainMatch('exam%70le.com', 'example.com')).toBe(false);
expect(isDomainMatch('exam\u0000ple.com', 'example.com')).toBe(false);
// Unicode/punycode attacks
expect(isDomainMatch('xn--e1awd7f.com', 'example.com')).toBe(false);
expect(isDomainMatch('еxample.com', 'example.com')).toBe(false); // cyrillic 'е'
// Edge cases
expect(isDomainMatch('', '')).toBe(true); // empty strings
expect(isDomainMatch(' ', ' ')).toBe(true); // spaces
expect(isDomainMatch('example.com', '')).toBe(false); // empty vs non-empty
expect(isDomainMatch('com', 'com')).toBe(true); // single part
expect(isDomainMatch('.com', 'com')).toBe(false); // dot prefix
// Mixed TLD tests
expect(isDomainMatch('example.co.uk.com', 'example.co.uk')).toBe(false);
expect(isDomainMatch('example.com.co.uk', 'example.co.uk')).toBe(false);
});
test('getRelativeUrl', () => {
// Helper to create a mock Context
const createMockContext = (url: string, headers: Record<string, string> = {}) => {
return {
req: {
url,
header: (name: string) => headers[name.toLowerCase()] || ''
}
} as Context;
};
// Test basic URL construction
const ctx1 = createMockContext('http://example.com');
expect(getRelativeUrl(ctx1, '/path')).toBe('http://example.com/path');
// Test with x-forwarded headers
const ctx2 = createMockContext('http://original.com', {
'x-forwarded-host': 'forwarded.com',
'x-forwarded-proto': 'https',
'x-forwarded-port': '443'
});
expect(getRelativeUrl(ctx2, '/path')).toBe('https://forwarded.com/path');
// Test with absolute URLs
const ctx4 = createMockContext('http://example.com');
expect(getRelativeUrl(ctx4, 'http://other.com/path')).toBe('http://other.com/path');
});