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 ` + + + + + + Not Authorized - ATAuth + + * { box-sizing: border-box; margin: 0; padding: 0; } + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #0f172a; + background-image: + radial-gradient(ellipse at 20% 50%, rgba(59, 130, 246, 0.15) 0%, transparent 50%), + radial-gradient(ellipse at 80% 20%, rgba(139, 92, 246, 0.12) 0%, transparent 50%); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + } + .container { + background: #1e293b; + border: 1px solid #334155; + border-radius: 16px; + padding: 40px; + width: 100%; + max-width: 420px; + box-shadow: 0 20px 60px rgba(0,0,0,0.5); + text-align: center; + } + h1 { font-size: 22px; margin-bottom: 12px; color: #fca5a5; font-weight: 600; } + .subtitle { color: #94a3b8; margin-bottom: 8px; font-size: 14px; line-height: 1.6; } + .client-name { color: #e2e8f0; font-weight: 600; } + + + +
+

Not Authorized

+

Your account does not have access to ${clientName.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')}.

+

Contact the administrator if you believe this is an error.

+
+ +`; +} + /** * Redirect with error parameters */ diff --git a/gateway/src/services/database.ts b/gateway/src/services/database.ts index c211a2e..e90b907 100644 --- a/gateway/src/services/database.ts +++ b/gateway/src/services/database.ts @@ -27,6 +27,7 @@ import type { ProxyAllowedOrigin, ProxyAuthRequest, ProxyAccessRule, + ClientAccessRule, } from '../types/index.js'; export class DatabaseService { @@ -303,8 +304,25 @@ export class DatabaseService { created_at INTEGER DEFAULT (unixepoch()) ); CREATE INDEX IF NOT EXISTS idx_proxy_access_rules_origin ON proxy_access_rules(origin_id); + + -- Client-level access rules (for OIDC clients and Legacy HMAC apps) + CREATE TABLE IF NOT EXISTS client_access_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + rule_type TEXT NOT NULL CHECK(rule_type IN ('allow', 'deny')), + subject_type TEXT NOT NULL CHECK(subject_type IN ('did', 'handle_pattern')), + subject_value TEXT NOT NULL, + description TEXT, + created_at INTEGER DEFAULT (unixepoch()) + ); + CREATE INDEX IF NOT EXISTS idx_client_access_rules_client ON client_access_rules(client_id); `); + // Add require_access_check column to apps table if it doesn't exist + if (!columnNames.includes('require_access_check')) { + this.db.exec('ALTER TABLE apps ADD COLUMN require_access_check INTEGER DEFAULT 0'); + } + // Audit log for admin operations this.db.exec(` CREATE TABLE IF NOT EXISTS audit_log ( @@ -1145,6 +1163,7 @@ export class DatabaseService { access_token_ttl_seconds: number; refresh_token_ttl_seconds: number; require_pkce: number; + require_access_check: number; token_endpoint_auth_method: string; created_at: string; } | undefined; @@ -1163,6 +1182,7 @@ export class DatabaseService { access_token_ttl_seconds: row.access_token_ttl_seconds, refresh_token_ttl_seconds: row.refresh_token_ttl_seconds, require_pkce: Boolean(row.require_pkce), + require_access_check: Boolean(row.require_access_check), token_endpoint_auth_method: row.token_endpoint_auth_method as 'client_secret_basic' | 'client_secret_post' | 'none', created_at: new Date(row.created_at), }; @@ -1223,6 +1243,7 @@ export class DatabaseService { access_token_ttl_seconds: number; refresh_token_ttl_seconds: number; require_pkce: number; + require_access_check: number; token_endpoint_auth_method: string; created_at: string; }>; @@ -1240,6 +1261,7 @@ export class DatabaseService { access_token_ttl_seconds: row.access_token_ttl_seconds, refresh_token_ttl_seconds: row.refresh_token_ttl_seconds, require_pkce: Boolean(row.require_pkce), + require_access_check: Boolean(row.require_access_check), token_endpoint_auth_method: row.token_endpoint_auth_method as 'client_secret_basic' | 'client_secret_post' | 'none', created_at: new Date(row.created_at), })); @@ -1252,6 +1274,7 @@ export class DatabaseService { grant_types?: string[]; allowed_scopes?: string[]; require_pkce?: boolean; + require_access_check?: boolean; token_endpoint_auth_method?: string; id_token_ttl_seconds?: number; access_token_ttl_seconds?: number; @@ -1284,6 +1307,10 @@ export class DatabaseService { sets.push('require_pkce = ?'); values.push(updates.require_pkce ? 1 : 0); } + if (updates.require_access_check !== undefined) { + sets.push('require_access_check = ?'); + values.push(updates.require_access_check ? 1 : 0); + } if (updates.token_endpoint_auth_method !== undefined) { sets.push('token_endpoint_auth_method = ?'); values.push(updates.token_endpoint_auth_method); @@ -1560,4 +1587,73 @@ export class DatabaseService { const row = stmt.get(origin) as { id: number } | undefined; return row?.id ?? null; } + + // Client access rule methods + + createClientAccessRule(rule: Omit): ClientAccessRule { + const stmt = this.db.prepare(` + INSERT INTO client_access_rules (client_id, rule_type, subject_type, subject_value, description) + VALUES (?, ?, ?, ?, ?) + `); + const result = stmt.run( + rule.client_id, rule.rule_type, rule.subject_type, + rule.subject_value, rule.description, + ); + return { + id: result.lastInsertRowid as number, + client_id: rule.client_id, + rule_type: rule.rule_type, + subject_type: rule.subject_type, + subject_value: rule.subject_value, + description: rule.description, + created_at: Math.floor(Date.now() / 1000), + }; + } + + deleteClientAccessRule(id: number): void { + this.db.prepare('DELETE FROM client_access_rules WHERE id = ?').run(id); + } + + listClientAccessRules(clientId?: string): ClientAccessRule[] { + if (clientId !== undefined) { + const stmt = this.db.prepare( + 'SELECT * FROM client_access_rules WHERE client_id = ? ORDER BY rule_type ASC, created_at ASC', + ); + return stmt.all(clientId) as ClientAccessRule[]; + } + const stmt = this.db.prepare( + 'SELECT * FROM client_access_rules ORDER BY client_id ASC, rule_type ASC, created_at ASC', + ); + return stmt.all() as ClientAccessRule[]; + } + + getClientAccessRulesForCheck(clientId: string): { + denyRules: ClientAccessRule[]; + originAllowRules: ClientAccessRule[]; + globalAllowRules: ClientAccessRule[]; + } { + const stmt = this.db.prepare( + 'SELECT * FROM client_access_rules WHERE client_id = ?', + ); + const rules = stmt.all(clientId) as ClientAccessRule[]; + + const denyRules: ClientAccessRule[] = []; + const originAllowRules: ClientAccessRule[] = []; + + for (const rule of rules) { + if (rule.rule_type === 'deny') { + denyRules.push(rule); + } else { + // All client rules are treated as client-specific (like origin-specific) + originAllowRules.push(rule); + } + } + + // No global rules for client access (all rules are client-specific) + return { denyRules, originAllowRules, globalAllowRules: [] }; + } + + setClientAccessCheck(clientId: string, enabled: boolean): void { + this.db.prepare('UPDATE apps SET require_access_check = ? WHERE id = ?').run(enabled ? 1 : 0, clientId); + } } diff --git a/gateway/src/types/index.ts b/gateway/src/types/index.ts index d65c0d8..6eb110c 100644 --- a/gateway/src/types/index.ts +++ b/gateway/src/types/index.ts @@ -45,6 +45,8 @@ export interface AppConfig { hmac_secret: string; token_ttl_seconds: number; callback_url?: string; + /** Whether to enforce client-level access rules (default: false) */ + require_access_check?: boolean | number; } export interface OAuthState { diff --git a/gateway/src/types/oidc.ts b/gateway/src/types/oidc.ts index 0ecfded..b25a703 100644 --- a/gateway/src/types/oidc.ts +++ b/gateway/src/types/oidc.ts @@ -34,6 +34,8 @@ export interface OIDCClientConfig { refresh_token_ttl_seconds: number; require_pkce: boolean; token_endpoint_auth_method: TokenEndpointAuthMethod; + /** Whether to enforce client-level access rules (default: false = open access) */ + require_access_check?: boolean; created_at: Date; } diff --git a/gateway/src/types/proxy.ts b/gateway/src/types/proxy.ts index bf75cf5..3d2e710 100644 --- a/gateway/src/types/proxy.ts +++ b/gateway/src/types/proxy.ts @@ -44,6 +44,17 @@ export interface ProxyAccessRule { created_at: number; } +/** An access control rule for an OIDC client or Legacy HMAC app */ +export interface ClientAccessRule { + id: number; + client_id: string; + rule_type: 'allow' | 'deny'; + subject_type: 'did' | 'handle_pattern'; + subject_value: string; + description: string | null; + created_at: number; +} + /** Result of an access check */ export interface AccessCheckResult { allowed: boolean; diff --git a/gateway/src/utils/access-check.ts b/gateway/src/utils/access-check.ts index 090188f..804d1d8 100644 --- a/gateway/src/utils/access-check.ts +++ b/gateway/src/utils/access-check.ts @@ -1,8 +1,8 @@ /** - * Forward-Auth Proxy Access Control + * Forward-Auth Proxy & Client Access Control * * Evaluates access rules to determine if a user (identified by DID and handle) - * is allowed to access a protected service. + * is allowed to access a protected service or client application. * * Evaluation order: * 1. Deny rules (per-origin + global) - if any match, reject @@ -11,7 +11,16 @@ * 4. Default deny */ -import type { ProxyAccessRule, AccessCheckResult } from '../types/proxy.js'; +import type { AccessCheckResult } from '../types/proxy.js'; + +/** Minimal rule shape required for access checking */ +interface AccessRule { + id: number; + rule_type: 'allow' | 'deny'; + subject_type: 'did' | 'handle_pattern'; + subject_value: string; + description: string | null; +} /** * Match a handle against a pattern. @@ -31,7 +40,7 @@ export function matchHandlePattern(pattern: string, handle: string): boolean { /** * Check if a DID or handle matches a given access rule. */ -function matchesRule(rule: ProxyAccessRule, did: string, handle: string): boolean { +function matchesRule(rule: AccessRule, did: string, handle: string): boolean { if (rule.subject_type === 'did') { return rule.subject_value === did; } @@ -39,15 +48,15 @@ function matchesRule(rule: ProxyAccessRule, did: string, handle: string): boolea } /** - * Evaluate access rules for a user attempting to access a protected origin. + * Evaluate access rules for a user attempting to access a protected origin or client. */ export function checkAccess( did: string, handle: string, rules: { - denyRules: ProxyAccessRule[]; - originAllowRules: ProxyAccessRule[]; - globalAllowRules: ProxyAccessRule[]; + denyRules: AccessRule[]; + originAllowRules: AccessRule[]; + globalAllowRules: AccessRule[]; }, ): AccessCheckResult { // 1. Check deny rules (both per-origin and global)