diff --git a/gateway/src/routes/admin.client-access.test.ts b/gateway/src/routes/admin.client-access.test.ts new file mode 100644 index 0000000..2fb70cf --- /dev/null +++ b/gateway/src/routes/admin.client-access.test.ts @@ -0,0 +1,378 @@ +/** + * Admin Client Access Rules API Tests + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { createAdminRoutes } from './admin.js'; +import { DatabaseService } from '../services/database.js'; + +const ADMIN_TOKEN = 'test-admin-token-secret'; + +function createTestApp(db: DatabaseService) { + const app = express(); + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + const router = createAdminRoutes(db, ADMIN_TOKEN, null, null, null); + app.use('/admin', router); + return app; +} + +function authHeader() { + return { Authorization: `Bearer ${ADMIN_TOKEN}` }; +} + +function createTestClient(db: DatabaseService, clientId = 'test-app', requireAccessCheck = false) { + db.upsertOIDCClient({ + id: clientId, + name: 'Test App', + client_type: 'oidc', + hmac_secret: 'test-secret-min-32-characters-long!!', + redirect_uris: ['https://app.example.com/callback'], + grant_types: ['authorization_code'], + allowed_scopes: ['openid'], + token_ttl_seconds: 3600, + id_token_ttl_seconds: 3600, + access_token_ttl_seconds: 3600, + refresh_token_ttl_seconds: 604800, + require_pkce: true, + require_access_check: requireAccessCheck, + token_endpoint_auth_method: 'client_secret_basic', + }); +} + +describe('Admin Client Access Rules', () => { + let db: DatabaseService; + let app: express.Application; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + app = createTestApp(db); + createTestClient(db); + }); + + afterEach(() => { + db.close(); + }); + + // List rules + + it('should return empty rules initially', async () => { + const res = await request(app) + .get('/admin/clients/test-app/access') + .set(authHeader()); + + expect(res.status).toBe(200); + expect(res.body.rules).toEqual([]); + expect(res.body.require_access_check).toBe(false); + }); + + it('should return 404 for unknown client', async () => { + const res = await request(app) + .get('/admin/clients/nonexistent/access') + .set(authHeader()); + + expect(res.status).toBe(404); + }); + + // Create rules + + it('should create an allow rule by DID', async () => { + const res = await request(app) + .post('/admin/clients/test-app/access') + .set(authHeader()) + .send({ + rule_type: 'allow', + subject_type: 'did', + subject_value: 'did:plc:testuser123', + description: 'Test user', + }); + + expect(res.status).toBe(201); + expect(res.body.client_id).toBe('test-app'); + expect(res.body.rule_type).toBe('allow'); + expect(res.body.subject_type).toBe('did'); + expect(res.body.subject_value).toBe('did:plc:testuser123'); + }); + + it('should create an allow rule by handle pattern', async () => { + const res = await request(app) + .post('/admin/clients/test-app/access') + .set(authHeader()) + .send({ + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*.bsky.social', + }); + + expect(res.status).toBe(201); + expect(res.body.subject_type).toBe('handle_pattern'); + expect(res.body.subject_value).toBe('*.bsky.social'); + }); + + it('should create a deny rule', async () => { + const res = await request(app) + .post('/admin/clients/test-app/access') + .set(authHeader()) + .send({ + rule_type: 'deny', + subject_type: 'handle_pattern', + subject_value: 'bad.actor.bsky.social', + }); + + expect(res.status).toBe(201); + expect(res.body.rule_type).toBe('deny'); + }); + + it('should reject invalid rule_type', async () => { + const res = await request(app) + .post('/admin/clients/test-app/access') + .set(authHeader()) + .send({ + rule_type: 'invalid', + subject_type: 'did', + subject_value: 'did:plc:test', + }); + + expect(res.status).toBe(400); + }); + + it('should reject invalid DID format', async () => { + const res = await request(app) + .post('/admin/clients/test-app/access') + .set(authHeader()) + .send({ + rule_type: 'allow', + subject_type: 'did', + subject_value: 'not-a-did', + }); + + expect(res.status).toBe(400); + }); + + it('should reject invalid handle pattern', async () => { + const res = await request(app) + .post('/admin/clients/test-app/access') + .set(authHeader()) + .send({ + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '..invalid', + }); + + expect(res.status).toBe(400); + }); + + // Delete rules + + it('should delete a rule', async () => { + // Create a rule first + const createRes = await request(app) + .post('/admin/clients/test-app/access') + .set(authHeader()) + .send({ + rule_type: 'allow', + subject_type: 'did', + subject_value: 'did:plc:testuser123', + }); + + const ruleId = createRes.body.id; + + const deleteRes = await request(app) + .delete(`/admin/clients/test-app/access/${ruleId}`) + .set(authHeader()); + + expect(deleteRes.status).toBe(200); + + // Verify it's gone + const listRes = await request(app) + .get('/admin/clients/test-app/access') + .set(authHeader()); + + expect(listRes.body.rules).toEqual([]); + }); + + // Toggle access check + + it('should enable access check', async () => { + const res = await request(app) + .patch('/admin/clients/test-app/access-check') + .set(authHeader()) + .send({ enabled: true }); + + expect(res.status).toBe(200); + expect(res.body.require_access_check).toBe(true); + + // Verify in list + const listRes = await request(app) + .get('/admin/clients/test-app/access') + .set(authHeader()); + + expect(listRes.body.require_access_check).toBe(true); + }); + + it('should disable access check', async () => { + // Enable first + await request(app) + .patch('/admin/clients/test-app/access-check') + .set(authHeader()) + .send({ enabled: true }); + + // Then disable + const res = await request(app) + .patch('/admin/clients/test-app/access-check') + .set(authHeader()) + .send({ enabled: false }); + + expect(res.status).toBe(200); + expect(res.body.require_access_check).toBe(false); + }); + + it('should reject non-boolean enabled', async () => { + const res = await request(app) + .patch('/admin/clients/test-app/access-check') + .set(authHeader()) + .send({ enabled: 'yes' }); + + expect(res.status).toBe(400); + }); + + // Access check dry-run + + it('should allow when access check is disabled', async () => { + const res = await request(app) + .post('/admin/clients/test-app/access/check') + .set(authHeader()) + .send({ did: 'did:plc:anyone', handle: 'anyone.bsky.social' }); + + expect(res.status).toBe(200); + expect(res.body.allowed).toBe(true); + expect(res.body.reason).toContain('not enabled'); + }); + + it('should deny when access check is enabled but no rules', async () => { + db.setClientAccessCheck('test-app', true); + + const res = await request(app) + .post('/admin/clients/test-app/access/check') + .set(authHeader()) + .send({ did: 'did:plc:anyone', handle: 'anyone.bsky.social' }); + + expect(res.status).toBe(200); + expect(res.body.allowed).toBe(false); + expect(res.body.reason).toContain('no rules'); + }); + + it('should allow matching DID when access check is enabled', async () => { + db.setClientAccessCheck('test-app', true); + db.createClientAccessRule({ + client_id: 'test-app', + rule_type: 'allow', + subject_type: 'did', + subject_value: 'did:plc:allowed', + description: 'Allowed user', + }); + + const res = await request(app) + .post('/admin/clients/test-app/access/check') + .set(authHeader()) + .send({ did: 'did:plc:allowed', handle: 'allowed.bsky.social' }); + + expect(res.status).toBe(200); + expect(res.body.allowed).toBe(true); + }); + + it('should deny non-matching DID when access check is enabled', async () => { + db.setClientAccessCheck('test-app', true); + db.createClientAccessRule({ + client_id: 'test-app', + rule_type: 'allow', + subject_type: 'did', + subject_value: 'did:plc:allowed', + description: 'Allowed user', + }); + + const res = await request(app) + .post('/admin/clients/test-app/access/check') + .set(authHeader()) + .send({ did: 'did:plc:blocked', handle: 'blocked.bsky.social' }); + + expect(res.status).toBe(200); + expect(res.body.allowed).toBe(false); + }); + + it('should deny trumps allow', async () => { + db.setClientAccessCheck('test-app', true); + db.createClientAccessRule({ + client_id: 'test-app', + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*', + description: 'Allow all', + }); + db.createClientAccessRule({ + client_id: 'test-app', + rule_type: 'deny', + subject_type: 'did', + subject_value: 'did:plc:baduser', + description: 'Block bad user', + }); + + const res = await request(app) + .post('/admin/clients/test-app/access/check') + .set(authHeader()) + .send({ did: 'did:plc:baduser', handle: 'bad.bsky.social' }); + + expect(res.status).toBe(200); + expect(res.body.allowed).toBe(false); + }); + + it('should allow handle pattern matching', async () => { + db.setClientAccessCheck('test-app', true); + db.createClientAccessRule({ + client_id: 'test-app', + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*.arcnode.xyz', + description: 'Arcnode users', + }); + + // Should allow matching handle + const allowRes = await request(app) + .post('/admin/clients/test-app/access/check') + .set(authHeader()) + .send({ did: 'did:plc:arcuser', handle: 'bkb.arcnode.xyz' }); + expect(allowRes.body.allowed).toBe(true); + + // Should deny non-matching handle + const denyRes = await request(app) + .post('/admin/clients/test-app/access/check') + .set(authHeader()) + .send({ did: 'did:plc:other', handle: 'other.bsky.social' }); + expect(denyRes.body.allowed).toBe(false); + }); + + // Rules don't leak between clients + + it('should not apply rules from one client to another', async () => { + createTestClient(db, 'other-app', true); + db.setClientAccessCheck('test-app', true); + + db.createClientAccessRule({ + client_id: 'other-app', + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*', + description: 'Allow all on other-app', + }); + + // test-app has access check on but no rules — should deny + const res = await request(app) + .post('/admin/clients/test-app/access/check') + .set(authHeader()) + .send({ did: 'did:plc:anyone', handle: 'anyone.bsky.social' }); + + expect(res.body.allowed).toBe(false); + }); +}); diff --git a/gateway/src/routes/admin.ts b/gateway/src/routes/admin.ts index ddcf47c..2678544 100644 --- a/gateway/src/routes/admin.ts +++ b/gateway/src/routes/admin.ts @@ -849,6 +849,152 @@ export function createAdminRoutes( res.json(result); }); + // ===== Client Access Rules ===== + + /** + * GET /admin/clients/:clientId/access + * List access rules for a client. + */ + router.get('/clients/:clientId/access', requireAdmin, async (req: Request, res: Response) => { + const clientId = String(req.params.clientId); + const client = db.getOIDCClient(clientId); + if (!client) { + throw httpError.notFound('client_not_found', `Client '${clientId}' not found`); + } + const rules = db.listClientAccessRules(clientId); + res.json({ rules, require_access_check: client.require_access_check }); + }); + + /** + * POST /admin/clients/:clientId/access + * Create an access rule for a client. + * + * Body: + * - rule_type: "allow" | "deny" + * - subject_type: "did" | "handle_pattern" + * - subject_value: string (DID or pattern like "*.example.com") + * - description: string (optional label) + */ + router.post('/clients/:clientId/access', requireAdmin, async (req: Request, res: Response) => { + const clientId = String(req.params.clientId); + const client = db.getOIDCClient(clientId); + if (!client) { + throw httpError.notFound('client_not_found', `Client '${clientId}' not found`); + } + + const { rule_type, subject_type, subject_value, description } = req.body; + + if (!rule_type || !subject_type || !subject_value) { + throw httpError.badRequest('missing_params', 'rule_type, subject_type, and subject_value are required'); + } + + if (!['allow', 'deny'].includes(rule_type)) { + throw httpError.badRequest('invalid_rule_type', 'rule_type must be "allow" or "deny"'); + } + + if (!['did', 'handle_pattern'].includes(subject_type)) { + throw httpError.badRequest('invalid_subject_type', 'subject_type must be "did" or "handle_pattern"'); + } + + if (subject_type === 'did' && !subject_value.startsWith('did:')) { + throw httpError.badRequest('invalid_did', 'DID must start with "did:"'); + } + + if (subject_type === 'handle_pattern') { + if (subject_value !== '*' && !subject_value.match(/^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$/)) { + throw httpError.badRequest('invalid_pattern', 'Handle pattern must be "*", "*.domain", or an exact handle'); + } + } + + const rule = db.createClientAccessRule({ + client_id: clientId, + rule_type, + subject_type, + subject_value, + description: description || null, + }); + + db.logAuditEvent('client.access_rule_create', 'admin', `${clientId}:${rule.id}`, `Created ${rule_type} rule for ${subject_type}:${subject_value}`, clientIp(req)); + + res.status(201).json(rule); + }); + + /** + * DELETE /admin/clients/:clientId/access/:ruleId + * Delete an access rule for a client. + */ + router.delete('/clients/:clientId/access/:ruleId', requireAdmin, async (req: Request, res: Response) => { + const clientId = String(req.params.clientId); + const ruleId = String(req.params.ruleId); + db.deleteClientAccessRule(parseInt(ruleId, 10)); + db.logAuditEvent('client.access_rule_delete', 'admin', `${clientId}:${ruleId}`, 'Deleted client access rule', clientIp(req)); + res.json({ message: 'Client access rule deleted' }); + }); + + /** + * POST /admin/clients/:clientId/access/check + * Test if a DID/handle would be allowed for a client. + */ + router.post('/clients/:clientId/access/check', requireAdmin, async (req: Request, res: Response) => { + const clientId = String(req.params.clientId); + const client = db.getOIDCClient(clientId); + if (!client) { + throw httpError.notFound('client_not_found', `Client '${clientId}' not found`); + } + + const { did, handle } = req.body; + if (!did || !handle) { + throw httpError.badRequest('missing_params', 'did and handle are required'); + } + + if (!client.require_access_check) { + return res.json({ + allowed: true, + matched_rule_id: null, + reason: 'Access check not enabled for this client (open access)', + }); + } + + const rules = db.getClientAccessRulesForCheck(clientId); + const totalRules = rules.denyRules.length + rules.originAllowRules.length + rules.globalAllowRules.length; + + if (totalRules === 0) { + return res.json({ + allowed: false, + matched_rule_id: null, + reason: 'Access check enabled but no rules configured (default deny)', + }); + } + + const result = checkAccess(did, handle, rules); + res.json(result); + }); + + /** + * PATCH /admin/clients/:clientId/access-check + * Toggle require_access_check for a client. + * + * Body: + * - enabled: boolean + */ + router.patch('/clients/:clientId/access-check', requireAdmin, async (req: Request, res: Response) => { + const clientId = String(req.params.clientId); + const client = db.getOIDCClient(clientId); + if (!client) { + throw httpError.notFound('client_not_found', `Client '${clientId}' not found`); + } + + const { enabled } = req.body; + if (typeof enabled !== 'boolean') { + throw httpError.badRequest('invalid_enabled', 'enabled must be a boolean'); + } + + db.setClientAccessCheck(clientId, enabled); + db.logAuditEvent('client.access_check_toggle', 'admin', clientId, `Set require_access_check=${enabled}`, clientIp(req)); + + res.json({ message: `Access check ${enabled ? 'enabled' : 'disabled'} for ${clientId}`, require_access_check: enabled }); + }); + // ===== Audit Log ===== /** diff --git a/gateway/src/routes/auth.ts b/gateway/src/routes/auth.ts index 0f0d483..473bb87 100644 --- a/gateway/src/routes/auth.ts +++ b/gateway/src/routes/auth.ts @@ -11,6 +11,7 @@ import { OAuthService } from '../services/oauth.js'; import { DatabaseService } from '../services/database.js'; import { createGatewayToken } from '../utils/hmac.js'; import { httpError } from '../utils/errors.js'; +import { checkAccess } from '../utils/access-check.js'; /** * Validate a redirect URI against an app's allowed callback URL. @@ -156,6 +157,19 @@ export function createAuthRoutes( const result = await oauth.handleCallback(params); + // Check client access rules + if (app.require_access_check) { + const rules = db.getClientAccessRulesForCheck(savedState.app_id); + const totalRules = rules.denyRules.length + rules.originAllowRules.length + rules.globalAllowRules.length; + if (totalRules > 0) { + const accessResult = checkAccess(result.did, result.handle, rules); + if (!accessResult.allowed) { + console.log(`[Auth ACL] Access denied for ${result.handle} (${result.did}) to ${savedState.app_id}: ${accessResult.reason}`); + throw httpError.forbidden('access_denied', 'You do not have access to this application'); + } + } + } + const existingMapping = db.getUserMapping(result.did, savedState.app_id); const userId = existingMapping?.user_id ?? null; diff --git a/gateway/src/routes/oidc/authorize.ts b/gateway/src/routes/oidc/authorize.ts index 14265e2..4b029fa 100644 --- a/gateway/src/routes/oidc/authorize.ts +++ b/gateway/src/routes/oidc/authorize.ts @@ -12,6 +12,7 @@ import type { OAuthService } from '../../services/oauth.js'; import type { PasskeyService } from '../../services/passkey.js'; import { parseScopes, hasOpenIdScope, validateScopes } from '../../services/oidc/claims.js'; import { isValidCodeChallengeMethod } from '../../services/oidc/pkce.js'; +import { checkAccess } from '../../utils/access-check.js'; export function createAuthorizeRouter( db: DatabaseService, @@ -585,6 +586,23 @@ export function createAuthorizeRouter( console.log(`[OIDC Passkey] Authenticated user: ${result.handle} (${result.did})`); + // Check client access rules + const oidcClient = db.getOIDCClient(authData.client_id); + if (oidcClient?.require_access_check) { + const rules = db.getClientAccessRulesForCheck(authData.client_id); + const totalRules = rules.denyRules.length + rules.originAllowRules.length + rules.globalAllowRules.length; + if (totalRules > 0) { + const accessResult = checkAccess(result.did, result.handle, rules); + if (!accessResult.allowed) { + console.log(`[OIDC ACL] Access denied for ${result.handle} (${result.did}) to ${authData.client_id}: ${accessResult.reason}`); + return res.status(403).json({ + error: 'access_denied', + error_description: 'You do not have access to this application', + }); + } + } + } + // Update the authorization code with the user's identity db.updateAuthorizationCodeUser(auth_code, result.did, result.handle); @@ -703,6 +721,21 @@ export function createAuthorizeRouter( // Update with user info db.updateAuthorizationCodeUser(oidcAuthCode, did, handle); + // Check client access rules + const oidcClient = db.getOIDCClient(authData.client_id); + if (oidcClient?.require_access_check) { + const rules = db.getClientAccessRulesForCheck(authData.client_id); + const totalRules = rules.denyRules.length + rules.originAllowRules.length + rules.globalAllowRules.length; + if (totalRules > 0) { + const accessResult = checkAccess(did, handle, rules); + if (!accessResult.allowed) { + console.log(`[OIDC ACL] Access denied for ${handle} (${did}) to ${authData.client_id}: ${accessResult.reason}`); + db.deleteOAuthState(state); + return res.status(403).type('html').send(renderAccessDeniedPage(oidcClient.name || authData.client_id, res.locals.cspNonce)); + } + } + } + // Build the redirect URL back to the original client const clientRedirectUrl = new URL(authData.redirect_uri); clientRedirectUrl.searchParams.set('code', oidcAuthCode); @@ -729,6 +762,56 @@ export function createAuthorizeRouter( return router; } +/** + * Render access denied page for OIDC clients + */ +function renderAccessDeniedPage(clientName: string, nonce?: string): string { + return ` + + +
+ + +Your account does not have access to ${clientName.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')}.
+Contact the administrator if you believe this is an error.
+