102 lines
3.4 KiB
TypeScript
102 lines
3.4 KiB
TypeScript
/**
|
|
* Tests for the identity-provider boundary.
|
|
*
|
|
* These cases pin the trust decisions shared by protected requests and profile
|
|
* creation. Membership remains deliberately outside this module.
|
|
*/
|
|
import { strict as assert } from 'node:assert';
|
|
import { generateKeyPairSync, type KeyObject } from 'node:crypto';
|
|
import { createServer, type Server } from 'node:http';
|
|
import type { AddressInfo } from 'node:net';
|
|
import { after, before, describe, it } from 'node:test';
|
|
import { exportJWK, SignJWT } from 'jose';
|
|
import {
|
|
createSupabaseAuthProvider,
|
|
type AuthProvider,
|
|
} from '../src/lib/auth-provider';
|
|
|
|
describe('Supabase auth provider', () => {
|
|
let server: Server;
|
|
let provider: AuthProvider;
|
|
let issuer: string;
|
|
let privateKey: KeyObject;
|
|
|
|
before(async () => {
|
|
const keys = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
privateKey = keys.privateKey;
|
|
const publicJwk = await exportJWK(keys.publicKey);
|
|
|
|
server = createServer((request, response) => {
|
|
if (request.url !== '/auth/v1/.well-known/jwks.json') {
|
|
response.writeHead(404).end();
|
|
return;
|
|
}
|
|
response.setHeader('content-type', 'application/json');
|
|
response.end(
|
|
JSON.stringify({
|
|
keys: [{ ...publicJwk, alg: 'RS256', kid: 'test-key', use: 'sig' }],
|
|
}),
|
|
);
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
|
|
const address = server.address() as AddressInfo;
|
|
const supabaseUrl = `http://127.0.0.1:${address.port}`;
|
|
issuer = `${supabaseUrl}/auth/v1`;
|
|
provider = createSupabaseAuthProvider(supabaseUrl);
|
|
});
|
|
|
|
after(
|
|
() =>
|
|
new Promise<void>((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
}),
|
|
);
|
|
|
|
async function sign(claims: Record<string, unknown>, tokenIssuer = issuer): Promise<string> {
|
|
return new SignJWT(claims)
|
|
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
|
.setIssuer(tokenIssuer)
|
|
.setExpirationTime('5m')
|
|
.sign(privateKey);
|
|
}
|
|
|
|
it('returns only the verified external identity claims', async () => {
|
|
const token = await sign({ sub: 'provider-user-1', email: 'Owner@Example.com' });
|
|
|
|
assert.deepEqual(await provider.verifyAccessToken(token), {
|
|
subject: 'provider-user-1',
|
|
email: 'Owner@Example.com',
|
|
});
|
|
});
|
|
|
|
it('rejects a correctly signed token issued for a different identity provider', async () => {
|
|
// Signature validity alone is insufficient: without the issuer check, a
|
|
// sibling deployment using the same key could authenticate here.
|
|
const token = await sign({ sub: 'provider-user-1' }, 'https://other.example/auth/v1');
|
|
|
|
await assert.rejects(provider.verifyAccessToken(token));
|
|
});
|
|
|
|
it('accepts a subject without email for protected requests', async () => {
|
|
// Existing members are joined by subject. Email is required only by the
|
|
// invite-gated profile flow, not as an extra condition on every request.
|
|
const token = await sign({ sub: 'provider-user-2' });
|
|
|
|
assert.deepEqual(await provider.verifyAccessToken(token), {
|
|
subject: 'provider-user-2',
|
|
email: undefined,
|
|
});
|
|
});
|
|
|
|
it('rejects a token with no stable subject', async () => {
|
|
const token = await sign({ email: 'owner@example.com' });
|
|
|
|
await assert.rejects(provider.verifyAccessToken(token));
|
|
});
|
|
});
|