Something went wrong. Try again.
AtAuth
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586/** * OIDC Flow E2E Tests * * Tests the complete OIDC authorization code flow */import { describe, it, expect, beforeAll, afterAll } from 'vitest';import request from 'supertest';import express from 'express';import { DatabaseService } from '../src/services/database.js';import { OIDCService } from '../src/services/oidc/index.js';import { createOIDCRouter } from '../src/routes/oidc/index.js';import { generateCodeVerifier, generateCodeChallenge } from '../src/services/oidc/pkce.js';import type { OAuthService } from '../src/services/oauth.js';import crypto from 'crypto';import fs from 'fs';import path from 'path';
// Test configurationconst TEST_ISSUER = 'https://auth.test.example.com';const TEST_CLIENT_ID = 'test-oidc-client';const TEST_REDIRECT_URI = 'https://app.test.example.com/callback';const TEST_DB_PATH = path.join(process.cwd(), 'test-oidc.db');
describe('OIDC Flow E2E', () => { let app: express.Application; let db: DatabaseService; let oidcService: OIDCService; let mockOAuthService: OAuthService;
beforeAll(async () => { // Clean up any existing test database if (fs.existsSync(TEST_DB_PATH)) { fs.unlinkSync(TEST_DB_PATH); }
// Initialize database db = new DatabaseService(TEST_DB_PATH);
// Create mock OAuth service mockOAuthService = { generateAuthUrl: async () => ({ url: 'https://pds.example.com/oauth/authorize?...', state: 'atproto-state-123', }), handleCallback: async () => ({ did: 'did:plc:testuser123', handle: 'testuser.bsky.social', }), } as unknown as OAuthService;
// Initialize OIDC service oidcService = new OIDCService(db, { issuer: TEST_ISSUER, keySecret: crypto.randomBytes(32).toString('hex'), });
// Ensure signing key exists await oidcService.initialize();
// Register test OIDC client db.upsertOIDCClient({ id: TEST_CLIENT_ID, name: 'Test OIDC App', client_type: 'oidc', hmac_secret: crypto.randomBytes(32).toString('hex'), redirect_uris: [TEST_REDIRECT_URI], allowed_scopes: ['openid', 'profile', 'email', 'offline_access'], grant_types: ['authorization_code', 'refresh_token'], require_pkce: true, token_endpoint_auth_method: 'none', token_ttl_seconds: 3600, id_token_ttl_seconds: 3600, access_token_ttl_seconds: 3600, refresh_token_ttl_seconds: 604800, });
// Create Express app app = express(); app.use(express.json()); app.use(express.urlencoded({ extended: true }));
// Mount OIDC routes const { wellKnownRouter, oauthRouter } = createOIDCRouter(db, oidcService, mockOAuthService); app.use('/.well-known', wellKnownRouter); app.use('/oauth', oauthRouter); });
afterAll(() => { // Clean up test database if (fs.existsSync(TEST_DB_PATH)) { fs.unlinkSync(TEST_DB_PATH); } });
describe('Discovery Endpoints', () => { it('should return valid OpenID configuration', async () => { const response = await request(app) .get('/.well-known/openid-configuration') .expect(200);
expect(response.body.issuer).toBe(TEST_ISSUER); expect(response.body.authorization_endpoint).toBe(`${TEST_ISSUER}/oauth/authorize`); expect(response.body.token_endpoint).toBe(`${TEST_ISSUER}/oauth/token`); expect(response.body.userinfo_endpoint).toBe(`${TEST_ISSUER}/oauth/userinfo`); expect(response.body.jwks_uri).toBe(`${TEST_ISSUER}/.well-known/jwks.json`); expect(response.body.scopes_supported).toContain('openid'); expect(response.body.response_types_supported).toContain('code'); expect(response.body.grant_types_supported).toContain('authorization_code'); expect(response.body.id_token_signing_alg_values_supported).toContain('ES256'); expect(response.body.code_challenge_methods_supported).toContain('S256'); });
it('should return valid JWKS', async () => { const response = await request(app) .get('/.well-known/jwks.json') .expect(200);
expect(response.body.keys).toBeDefined(); expect(Array.isArray(response.body.keys)).toBe(true); expect(response.body.keys.length).toBeGreaterThan(0);
const key = response.body.keys[0]; expect(key.kty).toBeDefined(); expect(key.alg).toBeDefined(); expect(key.kid).toBeDefined(); expect(key.use).toBe('sig'); }); });
describe('Authorization Endpoint', () => { it('should require all mandatory parameters', async () => { const response = await request(app) .get('/oauth/authorize') .expect(400);
expect(response.body.error).toBe('invalid_request'); expect(response.body.error_description).toContain('Missing required parameters'); });
it('should reject unsupported response_type', async () => { const response = await request(app) .get('/oauth/authorize') .query({ response_type: 'token', client_id: TEST_CLIENT_ID, redirect_uri: TEST_REDIRECT_URI, scope: 'openid', state: 'test-state', }) .expect(400);
expect(response.body.error).toBe('unsupported_response_type'); });
it('should reject unknown client_id', async () => { const response = await request(app) .get('/oauth/authorize') .query({ response_type: 'code', client_id: 'unknown-client', redirect_uri: TEST_REDIRECT_URI, scope: 'openid', state: 'test-state', }) .expect(400);
expect(response.body.error).toBe('invalid_client'); });
it('should reject invalid redirect_uri', async () => { const response = await request(app) .get('/oauth/authorize') .query({ response_type: 'code', client_id: TEST_CLIENT_ID, redirect_uri: 'https://malicious.example.com/callback', scope: 'openid', state: 'test-state', }) .expect(400);
expect(response.body.error).toBe('invalid_request'); expect(response.body.error_description).toContain('redirect_uri'); });
it('should require PKCE code_challenge when client requires it', async () => { const response = await request(app) .get('/oauth/authorize') .query({ response_type: 'code', client_id: TEST_CLIENT_ID, redirect_uri: TEST_REDIRECT_URI, scope: 'openid', state: 'test-state', });
// Should redirect with error expect(response.status).toBe(302); expect(response.headers.location).toContain('error=invalid_request'); expect(response.headers.location).toContain('code_challenge'); });
it('should return login page with valid parameters', async () => { const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier);
const response = await request(app) .get('/oauth/authorize') .query({ response_type: 'code', client_id: TEST_CLIENT_ID, redirect_uri: TEST_REDIRECT_URI, scope: 'openid profile', state: 'test-state-123', code_challenge: codeChallenge, code_challenge_method: 'S256', }) .expect(200);
expect(response.type).toBe('text/html'); expect(response.text).toContain('Sign in'); expect(response.text).toContain('handle'); });
it('should redirect with error for missing openid scope', async () => { const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier);
const response = await request(app) .get('/oauth/authorize') .query({ response_type: 'code', client_id: TEST_CLIENT_ID, redirect_uri: TEST_REDIRECT_URI, scope: 'profile', state: 'test-state', code_challenge: codeChallenge, code_challenge_method: 'S256', });
expect(response.status).toBe(302); expect(response.headers.location).toContain('error=invalid_scope'); expect(response.headers.location).toContain('openid'); }); });
describe('Token Endpoint', () => { let authorizationCode: string; let codeVerifier: string;
beforeAll(async () => { // Create an authorization code for testing codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); authorizationCode = crypto.randomBytes(32).toString('base64url');
db.saveAuthorizationCode({ code: authorizationCode, client_id: TEST_CLIENT_ID, redirect_uri: TEST_REDIRECT_URI, scope: 'openid profile', state: 'test-state', nonce: 'test-nonce', code_challenge: codeChallenge, code_challenge_method: 'S256', did: 'did:plc:testuser123', handle: 'testuser.bsky.social', created_at: Math.floor(Date.now() / 1000), expires_at: Math.floor(Date.now() / 1000) + 600, used: false, }); });
it('should reject missing grant_type', async () => { const response = await request(app) .post('/oauth/token') .send({ code: authorizationCode, redirect_uri: TEST_REDIRECT_URI, client_id: TEST_CLIENT_ID, }) .expect(400);
expect(response.body.error).toBe('invalid_request'); });
it('should reject unsupported grant_type', async () => { const response = await request(app) .post('/oauth/token') .send({ grant_type: 'password', code: authorizationCode, redirect_uri: TEST_REDIRECT_URI, client_id: TEST_CLIENT_ID, }) .expect(400);
expect(response.body.error).toBe('unsupported_grant_type'); });
it('should reject invalid authorization code', async () => { const response = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', code: 'invalid-code', redirect_uri: TEST_REDIRECT_URI, client_id: TEST_CLIENT_ID, code_verifier: codeVerifier, }) .expect(400);
expect(response.body.error).toBe('invalid_grant'); });
it('should reject invalid PKCE verifier', async () => { // Create a fresh auth code const freshCode = crypto.randomBytes(32).toString('base64url'); const freshVerifier = generateCodeVerifier(); const freshChallenge = generateCodeChallenge(freshVerifier);
db.saveAuthorizationCode({ code: freshCode, client_id: TEST_CLIENT_ID, redirect_uri: TEST_REDIRECT_URI, scope: 'openid', code_challenge: freshChallenge, code_challenge_method: 'S256', did: 'did:plc:testuser123', handle: 'testuser.bsky.social', created_at: Math.floor(Date.now() / 1000), expires_at: Math.floor(Date.now() / 1000) + 600, used: false, });
const response = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', code: freshCode, redirect_uri: TEST_REDIRECT_URI, client_id: TEST_CLIENT_ID, code_verifier: 'wrong-verifier-that-is-at-least-43-chars-long-for-validity', }) .expect(400);
expect(response.body.error).toBe('invalid_grant'); expect(response.body.error_description).toContain('code_verifier'); });
it('should exchange valid code for tokens', async () => { // Create a fresh auth code const freshCode = crypto.randomBytes(32).toString('base64url'); const freshVerifier = generateCodeVerifier(); const freshChallenge = generateCodeChallenge(freshVerifier);
db.saveAuthorizationCode({ code: freshCode, client_id: TEST_CLIENT_ID, redirect_uri: TEST_REDIRECT_URI, scope: 'openid profile', nonce: 'test-nonce', code_challenge: freshChallenge, code_challenge_method: 'S256', did: 'did:plc:testuser123', handle: 'testuser.bsky.social', created_at: Math.floor(Date.now() / 1000), expires_at: Math.floor(Date.now() / 1000) + 600, used: false, });
const response = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', code: freshCode, redirect_uri: TEST_REDIRECT_URI, client_id: TEST_CLIENT_ID, code_verifier: freshVerifier, }) .expect(200);
expect(response.body.access_token).toBeDefined(); expect(response.body.token_type).toBe('Bearer'); expect(response.body.expires_in).toBeDefined(); expect(response.body.id_token).toBeDefined();
// Verify tokens are valid JWTs expect(response.body.access_token.split('.')).toHaveLength(3); expect(response.body.id_token.split('.')).toHaveLength(3); });
it('should not allow code reuse', async () => { // Create a fresh auth code const freshCode = crypto.randomBytes(32).toString('base64url'); const freshVerifier = generateCodeVerifier(); const freshChallenge = generateCodeChallenge(freshVerifier);
db.saveAuthorizationCode({ code: freshCode, client_id: TEST_CLIENT_ID, redirect_uri: TEST_REDIRECT_URI, scope: 'openid', code_challenge: freshChallenge, code_challenge_method: 'S256', did: 'did:plc:testuser123', handle: 'testuser.bsky.social', created_at: Math.floor(Date.now() / 1000), expires_at: Math.floor(Date.now() / 1000) + 600, used: false, });
// First request should succeed await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', code: freshCode, redirect_uri: TEST_REDIRECT_URI, client_id: TEST_CLIENT_ID, code_verifier: freshVerifier, }) .expect(200);
// Second request should fail const response = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', code: freshCode, redirect_uri: TEST_REDIRECT_URI, client_id: TEST_CLIENT_ID, code_verifier: freshVerifier, }) .expect(400);
expect(response.body.error).toBe('invalid_grant'); }); });
describe('UserInfo Endpoint', () => { let accessToken: string;
beforeAll(async () => { // Get a valid access token const freshCode = crypto.randomBytes(32).toString('base64url'); const freshVerifier = generateCodeVerifier(); const freshChallenge = generateCodeChallenge(freshVerifier);
db.saveAuthorizationCode({ code: freshCode, client_id: TEST_CLIENT_ID, redirect_uri: TEST_REDIRECT_URI, scope: 'openid profile', code_challenge: freshChallenge, code_challenge_method: 'S256', did: 'did:plc:userinfotest', handle: 'userinfotest.bsky.social', created_at: Math.floor(Date.now() / 1000), expires_at: Math.floor(Date.now() / 1000) + 600, used: false, });
const response = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', code: freshCode, redirect_uri: TEST_REDIRECT_URI, client_id: TEST_CLIENT_ID, code_verifier: freshVerifier, });
accessToken = response.body.access_token; });
it('should require authorization', async () => { const response = await request(app) .get('/oauth/userinfo') .expect(401);
expect(response.body.error).toBe('invalid_token'); });
it('should reject invalid tokens', async () => { const response = await request(app) .get('/oauth/userinfo') .set('Authorization', 'Bearer invalid-token') .expect(401);
expect(response.body.error).toBe('invalid_token'); });
it('should return user info with valid token', async () => { const response = await request(app) .get('/oauth/userinfo') .set('Authorization', `Bearer ${accessToken}`) .expect(200);
// sub is always returned expect(response.body.sub).toBe('did:plc:userinfotest'); // Note: handle/preferred_username require user mapping which isn't set up in this test });
it('should support POST method', async () => { const response = await request(app) .post('/oauth/userinfo') .set('Authorization', `Bearer ${accessToken}`) .expect(200);
expect(response.body.sub).toBe('did:plc:userinfotest'); }); });
describe('Token Revocation', () => { let accessToken: string;
beforeAll(async () => { const freshCode = crypto.randomBytes(32).toString('base64url'); const freshVerifier = generateCodeVerifier(); const freshChallenge = generateCodeChallenge(freshVerifier);
db.saveAuthorizationCode({ code: freshCode, client_id: TEST_CLIENT_ID, redirect_uri: TEST_REDIRECT_URI, scope: 'openid', code_challenge: freshChallenge, code_challenge_method: 'S256', did: 'did:plc:revoketest', handle: 'revoketest.bsky.social', created_at: Math.floor(Date.now() / 1000), expires_at: Math.floor(Date.now() / 1000) + 600, used: false, });
const response = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', code: freshCode, redirect_uri: TEST_REDIRECT_URI, client_id: TEST_CLIENT_ID, code_verifier: freshVerifier, });
accessToken = response.body.access_token; });
it('should accept revocation request', async () => { await request(app) .post('/oauth/revoke') .send({ token: accessToken, client_id: TEST_CLIENT_ID, }) .expect(200); });
it('should accept revocation of already revoked/invalid token', async () => { // Per RFC 7009, revocation endpoint should always return 200 await request(app) .post('/oauth/revoke') .send({ token: 'already-revoked-or-invalid', client_id: TEST_CLIENT_ID, }) .expect(200); }); });
describe('End Session / Logout', () => { it('should handle logout request', async () => { const response = await request(app) .get('/oauth/end_session') .query({ post_logout_redirect_uri: TEST_REDIRECT_URI, state: 'logout-state', });
// Should redirect or return success expect([200, 302]).toContain(response.status); }); });});