AtAuth
7

Configure Feed

Select the types of activity you want to include in your feed.

feat: per-client access rules for OIDC and Legacy HMAC apps

Add user-level access gating to OIDC clients and Legacy HMAC apps,
matching the existing forward-auth proxy access control pattern.

- New `client_access_rules` table for per-client allow/deny rules
- New `require_access_check` flag on apps table (default: off)
- Enforcement in OIDC authorize callback + passkey flow
- Enforcement in Legacy HMAC auth callback
- Admin API: CRUD endpoints + dry-run access check
- Generic `checkAccess()` now works with both proxy and client rules
- 19 new tests (423 total, all passing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Bryan Brooks (Mar 11, 2026, 8:55 PM -0500) 5ce3861f bfa3fd58

+749 -8
+378
gateway/src/routes/admin.client-access.test.ts
··· 1 + /** 2 + * Admin Client Access Rules API Tests 3 + */ 4 + import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 5 + import express from 'express'; 6 + import request from 'supertest'; 7 + import { createAdminRoutes } from './admin.js'; 8 + import { DatabaseService } from '../services/database.js'; 9 + 10 + const ADMIN_TOKEN = 'test-admin-token-secret'; 11 + 12 + function createTestApp(db: DatabaseService) { 13 + const app = express(); 14 + app.use(express.json()); 15 + app.use(express.urlencoded({ extended: true })); 16 + 17 + const router = createAdminRoutes(db, ADMIN_TOKEN, null, null, null); 18 + app.use('/admin', router); 19 + return app; 20 + } 21 + 22 + function authHeader() { 23 + return { Authorization: `Bearer ${ADMIN_TOKEN}` }; 24 + } 25 + 26 + function createTestClient(db: DatabaseService, clientId = 'test-app', requireAccessCheck = false) { 27 + db.upsertOIDCClient({ 28 + id: clientId, 29 + name: 'Test App', 30 + client_type: 'oidc', 31 + hmac_secret: 'test-secret-min-32-characters-long!!', 32 + redirect_uris: ['https://app.example.com/callback'], 33 + grant_types: ['authorization_code'], 34 + allowed_scopes: ['openid'], 35 + token_ttl_seconds: 3600, 36 + id_token_ttl_seconds: 3600, 37 + access_token_ttl_seconds: 3600, 38 + refresh_token_ttl_seconds: 604800, 39 + require_pkce: true, 40 + require_access_check: requireAccessCheck, 41 + token_endpoint_auth_method: 'client_secret_basic', 42 + }); 43 + } 44 + 45 + describe('Admin Client Access Rules', () => { 46 + let db: DatabaseService; 47 + let app: express.Application; 48 + 49 + beforeEach(() => { 50 + db = new DatabaseService(':memory:'); 51 + app = createTestApp(db); 52 + createTestClient(db); 53 + }); 54 + 55 + afterEach(() => { 56 + db.close(); 57 + }); 58 + 59 + // List rules 60 + 61 + it('should return empty rules initially', async () => { 62 + const res = await request(app) 63 + .get('/admin/clients/test-app/access') 64 + .set(authHeader()); 65 + 66 + expect(res.status).toBe(200); 67 + expect(res.body.rules).toEqual([]); 68 + expect(res.body.require_access_check).toBe(false); 69 + }); 70 + 71 + it('should return 404 for unknown client', async () => { 72 + const res = await request(app) 73 + .get('/admin/clients/nonexistent/access') 74 + .set(authHeader()); 75 + 76 + expect(res.status).toBe(404); 77 + }); 78 + 79 + // Create rules 80 + 81 + it('should create an allow rule by DID', async () => { 82 + const res = await request(app) 83 + .post('/admin/clients/test-app/access') 84 + .set(authHeader()) 85 + .send({ 86 + rule_type: 'allow', 87 + subject_type: 'did', 88 + subject_value: 'did:plc:testuser123', 89 + description: 'Test user', 90 + }); 91 + 92 + expect(res.status).toBe(201); 93 + expect(res.body.client_id).toBe('test-app'); 94 + expect(res.body.rule_type).toBe('allow'); 95 + expect(res.body.subject_type).toBe('did'); 96 + expect(res.body.subject_value).toBe('did:plc:testuser123'); 97 + }); 98 + 99 + it('should create an allow rule by handle pattern', async () => { 100 + const res = await request(app) 101 + .post('/admin/clients/test-app/access') 102 + .set(authHeader()) 103 + .send({ 104 + rule_type: 'allow', 105 + subject_type: 'handle_pattern', 106 + subject_value: '*.bsky.social', 107 + }); 108 + 109 + expect(res.status).toBe(201); 110 + expect(res.body.subject_type).toBe('handle_pattern'); 111 + expect(res.body.subject_value).toBe('*.bsky.social'); 112 + }); 113 + 114 + it('should create a deny rule', async () => { 115 + const res = await request(app) 116 + .post('/admin/clients/test-app/access') 117 + .set(authHeader()) 118 + .send({ 119 + rule_type: 'deny', 120 + subject_type: 'handle_pattern', 121 + subject_value: 'bad.actor.bsky.social', 122 + }); 123 + 124 + expect(res.status).toBe(201); 125 + expect(res.body.rule_type).toBe('deny'); 126 + }); 127 + 128 + it('should reject invalid rule_type', async () => { 129 + const res = await request(app) 130 + .post('/admin/clients/test-app/access') 131 + .set(authHeader()) 132 + .send({ 133 + rule_type: 'invalid', 134 + subject_type: 'did', 135 + subject_value: 'did:plc:test', 136 + }); 137 + 138 + expect(res.status).toBe(400); 139 + }); 140 + 141 + it('should reject invalid DID format', async () => { 142 + const res = await request(app) 143 + .post('/admin/clients/test-app/access') 144 + .set(authHeader()) 145 + .send({ 146 + rule_type: 'allow', 147 + subject_type: 'did', 148 + subject_value: 'not-a-did', 149 + }); 150 + 151 + expect(res.status).toBe(400); 152 + }); 153 + 154 + it('should reject invalid handle pattern', async () => { 155 + const res = await request(app) 156 + .post('/admin/clients/test-app/access') 157 + .set(authHeader()) 158 + .send({ 159 + rule_type: 'allow', 160 + subject_type: 'handle_pattern', 161 + subject_value: '..invalid', 162 + }); 163 + 164 + expect(res.status).toBe(400); 165 + }); 166 + 167 + // Delete rules 168 + 169 + it('should delete a rule', async () => { 170 + // Create a rule first 171 + const createRes = await request(app) 172 + .post('/admin/clients/test-app/access') 173 + .set(authHeader()) 174 + .send({ 175 + rule_type: 'allow', 176 + subject_type: 'did', 177 + subject_value: 'did:plc:testuser123', 178 + }); 179 + 180 + const ruleId = createRes.body.id; 181 + 182 + const deleteRes = await request(app) 183 + .delete(`/admin/clients/test-app/access/${ruleId}`) 184 + .set(authHeader()); 185 + 186 + expect(deleteRes.status).toBe(200); 187 + 188 + // Verify it's gone 189 + const listRes = await request(app) 190 + .get('/admin/clients/test-app/access') 191 + .set(authHeader()); 192 + 193 + expect(listRes.body.rules).toEqual([]); 194 + }); 195 + 196 + // Toggle access check 197 + 198 + it('should enable access check', async () => { 199 + const res = await request(app) 200 + .patch('/admin/clients/test-app/access-check') 201 + .set(authHeader()) 202 + .send({ enabled: true }); 203 + 204 + expect(res.status).toBe(200); 205 + expect(res.body.require_access_check).toBe(true); 206 + 207 + // Verify in list 208 + const listRes = await request(app) 209 + .get('/admin/clients/test-app/access') 210 + .set(authHeader()); 211 + 212 + expect(listRes.body.require_access_check).toBe(true); 213 + }); 214 + 215 + it('should disable access check', async () => { 216 + // Enable first 217 + await request(app) 218 + .patch('/admin/clients/test-app/access-check') 219 + .set(authHeader()) 220 + .send({ enabled: true }); 221 + 222 + // Then disable 223 + const res = await request(app) 224 + .patch('/admin/clients/test-app/access-check') 225 + .set(authHeader()) 226 + .send({ enabled: false }); 227 + 228 + expect(res.status).toBe(200); 229 + expect(res.body.require_access_check).toBe(false); 230 + }); 231 + 232 + it('should reject non-boolean enabled', async () => { 233 + const res = await request(app) 234 + .patch('/admin/clients/test-app/access-check') 235 + .set(authHeader()) 236 + .send({ enabled: 'yes' }); 237 + 238 + expect(res.status).toBe(400); 239 + }); 240 + 241 + // Access check dry-run 242 + 243 + it('should allow when access check is disabled', async () => { 244 + const res = await request(app) 245 + .post('/admin/clients/test-app/access/check') 246 + .set(authHeader()) 247 + .send({ did: 'did:plc:anyone', handle: 'anyone.bsky.social' }); 248 + 249 + expect(res.status).toBe(200); 250 + expect(res.body.allowed).toBe(true); 251 + expect(res.body.reason).toContain('not enabled'); 252 + }); 253 + 254 + it('should deny when access check is enabled but no rules', async () => { 255 + db.setClientAccessCheck('test-app', true); 256 + 257 + const res = await request(app) 258 + .post('/admin/clients/test-app/access/check') 259 + .set(authHeader()) 260 + .send({ did: 'did:plc:anyone', handle: 'anyone.bsky.social' }); 261 + 262 + expect(res.status).toBe(200); 263 + expect(res.body.allowed).toBe(false); 264 + expect(res.body.reason).toContain('no rules'); 265 + }); 266 + 267 + it('should allow matching DID when access check is enabled', async () => { 268 + db.setClientAccessCheck('test-app', true); 269 + db.createClientAccessRule({ 270 + client_id: 'test-app', 271 + rule_type: 'allow', 272 + subject_type: 'did', 273 + subject_value: 'did:plc:allowed', 274 + description: 'Allowed user', 275 + }); 276 + 277 + const res = await request(app) 278 + .post('/admin/clients/test-app/access/check') 279 + .set(authHeader()) 280 + .send({ did: 'did:plc:allowed', handle: 'allowed.bsky.social' }); 281 + 282 + expect(res.status).toBe(200); 283 + expect(res.body.allowed).toBe(true); 284 + }); 285 + 286 + it('should deny non-matching DID when access check is enabled', async () => { 287 + db.setClientAccessCheck('test-app', true); 288 + db.createClientAccessRule({ 289 + client_id: 'test-app', 290 + rule_type: 'allow', 291 + subject_type: 'did', 292 + subject_value: 'did:plc:allowed', 293 + description: 'Allowed user', 294 + }); 295 + 296 + const res = await request(app) 297 + .post('/admin/clients/test-app/access/check') 298 + .set(authHeader()) 299 + .send({ did: 'did:plc:blocked', handle: 'blocked.bsky.social' }); 300 + 301 + expect(res.status).toBe(200); 302 + expect(res.body.allowed).toBe(false); 303 + }); 304 + 305 + it('should deny trumps allow', async () => { 306 + db.setClientAccessCheck('test-app', true); 307 + db.createClientAccessRule({ 308 + client_id: 'test-app', 309 + rule_type: 'allow', 310 + subject_type: 'handle_pattern', 311 + subject_value: '*', 312 + description: 'Allow all', 313 + }); 314 + db.createClientAccessRule({ 315 + client_id: 'test-app', 316 + rule_type: 'deny', 317 + subject_type: 'did', 318 + subject_value: 'did:plc:baduser', 319 + description: 'Block bad user', 320 + }); 321 + 322 + const res = await request(app) 323 + .post('/admin/clients/test-app/access/check') 324 + .set(authHeader()) 325 + .send({ did: 'did:plc:baduser', handle: 'bad.bsky.social' }); 326 + 327 + expect(res.status).toBe(200); 328 + expect(res.body.allowed).toBe(false); 329 + }); 330 + 331 + it('should allow handle pattern matching', async () => { 332 + db.setClientAccessCheck('test-app', true); 333 + db.createClientAccessRule({ 334 + client_id: 'test-app', 335 + rule_type: 'allow', 336 + subject_type: 'handle_pattern', 337 + subject_value: '*.arcnode.xyz', 338 + description: 'Arcnode users', 339 + }); 340 + 341 + // Should allow matching handle 342 + const allowRes = await request(app) 343 + .post('/admin/clients/test-app/access/check') 344 + .set(authHeader()) 345 + .send({ did: 'did:plc:arcuser', handle: 'bkb.arcnode.xyz' }); 346 + expect(allowRes.body.allowed).toBe(true); 347 + 348 + // Should deny non-matching handle 349 + const denyRes = await request(app) 350 + .post('/admin/clients/test-app/access/check') 351 + .set(authHeader()) 352 + .send({ did: 'did:plc:other', handle: 'other.bsky.social' }); 353 + expect(denyRes.body.allowed).toBe(false); 354 + }); 355 + 356 + // Rules don't leak between clients 357 + 358 + it('should not apply rules from one client to another', async () => { 359 + createTestClient(db, 'other-app', true); 360 + db.setClientAccessCheck('test-app', true); 361 + 362 + db.createClientAccessRule({ 363 + client_id: 'other-app', 364 + rule_type: 'allow', 365 + subject_type: 'handle_pattern', 366 + subject_value: '*', 367 + description: 'Allow all on other-app', 368 + }); 369 + 370 + // test-app has access check on but no rules — should deny 371 + const res = await request(app) 372 + .post('/admin/clients/test-app/access/check') 373 + .set(authHeader()) 374 + .send({ did: 'did:plc:anyone', handle: 'anyone.bsky.social' }); 375 + 376 + expect(res.body.allowed).toBe(false); 377 + }); 378 + });
+146
gateway/src/routes/admin.ts
··· 849 849 res.json(result); 850 850 }); 851 851 852 + // ===== Client Access Rules ===== 853 + 854 + /** 855 + * GET /admin/clients/:clientId/access 856 + * List access rules for a client. 857 + */ 858 + router.get('/clients/:clientId/access', requireAdmin, async (req: Request, res: Response) => { 859 + const clientId = String(req.params.clientId); 860 + const client = db.getOIDCClient(clientId); 861 + if (!client) { 862 + throw httpError.notFound('client_not_found', `Client '${clientId}' not found`); 863 + } 864 + const rules = db.listClientAccessRules(clientId); 865 + res.json({ rules, require_access_check: client.require_access_check }); 866 + }); 867 + 868 + /** 869 + * POST /admin/clients/:clientId/access 870 + * Create an access rule for a client. 871 + * 872 + * Body: 873 + * - rule_type: "allow" | "deny" 874 + * - subject_type: "did" | "handle_pattern" 875 + * - subject_value: string (DID or pattern like "*.example.com") 876 + * - description: string (optional label) 877 + */ 878 + router.post('/clients/:clientId/access', requireAdmin, async (req: Request, res: Response) => { 879 + const clientId = String(req.params.clientId); 880 + const client = db.getOIDCClient(clientId); 881 + if (!client) { 882 + throw httpError.notFound('client_not_found', `Client '${clientId}' not found`); 883 + } 884 + 885 + const { rule_type, subject_type, subject_value, description } = req.body; 886 + 887 + if (!rule_type || !subject_type || !subject_value) { 888 + throw httpError.badRequest('missing_params', 'rule_type, subject_type, and subject_value are required'); 889 + } 890 + 891 + if (!['allow', 'deny'].includes(rule_type)) { 892 + throw httpError.badRequest('invalid_rule_type', 'rule_type must be "allow" or "deny"'); 893 + } 894 + 895 + if (!['did', 'handle_pattern'].includes(subject_type)) { 896 + throw httpError.badRequest('invalid_subject_type', 'subject_type must be "did" or "handle_pattern"'); 897 + } 898 + 899 + if (subject_type === 'did' && !subject_value.startsWith('did:')) { 900 + throw httpError.badRequest('invalid_did', 'DID must start with "did:"'); 901 + } 902 + 903 + if (subject_type === 'handle_pattern') { 904 + if (subject_value !== '*' && !subject_value.match(/^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$/)) { 905 + throw httpError.badRequest('invalid_pattern', 'Handle pattern must be "*", "*.domain", or an exact handle'); 906 + } 907 + } 908 + 909 + const rule = db.createClientAccessRule({ 910 + client_id: clientId, 911 + rule_type, 912 + subject_type, 913 + subject_value, 914 + description: description || null, 915 + }); 916 + 917 + db.logAuditEvent('client.access_rule_create', 'admin', `${clientId}:${rule.id}`, `Created ${rule_type} rule for ${subject_type}:${subject_value}`, clientIp(req)); 918 + 919 + res.status(201).json(rule); 920 + }); 921 + 922 + /** 923 + * DELETE /admin/clients/:clientId/access/:ruleId 924 + * Delete an access rule for a client. 925 + */ 926 + router.delete('/clients/:clientId/access/:ruleId', requireAdmin, async (req: Request, res: Response) => { 927 + const clientId = String(req.params.clientId); 928 + const ruleId = String(req.params.ruleId); 929 + db.deleteClientAccessRule(parseInt(ruleId, 10)); 930 + db.logAuditEvent('client.access_rule_delete', 'admin', `${clientId}:${ruleId}`, 'Deleted client access rule', clientIp(req)); 931 + res.json({ message: 'Client access rule deleted' }); 932 + }); 933 + 934 + /** 935 + * POST /admin/clients/:clientId/access/check 936 + * Test if a DID/handle would be allowed for a client. 937 + */ 938 + router.post('/clients/:clientId/access/check', requireAdmin, async (req: Request, res: Response) => { 939 + const clientId = String(req.params.clientId); 940 + const client = db.getOIDCClient(clientId); 941 + if (!client) { 942 + throw httpError.notFound('client_not_found', `Client '${clientId}' not found`); 943 + } 944 + 945 + const { did, handle } = req.body; 946 + if (!did || !handle) { 947 + throw httpError.badRequest('missing_params', 'did and handle are required'); 948 + } 949 + 950 + if (!client.require_access_check) { 951 + return res.json({ 952 + allowed: true, 953 + matched_rule_id: null, 954 + reason: 'Access check not enabled for this client (open access)', 955 + }); 956 + } 957 + 958 + const rules = db.getClientAccessRulesForCheck(clientId); 959 + const totalRules = rules.denyRules.length + rules.originAllowRules.length + rules.globalAllowRules.length; 960 + 961 + if (totalRules === 0) { 962 + return res.json({ 963 + allowed: false, 964 + matched_rule_id: null, 965 + reason: 'Access check enabled but no rules configured (default deny)', 966 + }); 967 + } 968 + 969 + const result = checkAccess(did, handle, rules); 970 + res.json(result); 971 + }); 972 + 973 + /** 974 + * PATCH /admin/clients/:clientId/access-check 975 + * Toggle require_access_check for a client. 976 + * 977 + * Body: 978 + * - enabled: boolean 979 + */ 980 + router.patch('/clients/:clientId/access-check', requireAdmin, async (req: Request, res: Response) => { 981 + const clientId = String(req.params.clientId); 982 + const client = db.getOIDCClient(clientId); 983 + if (!client) { 984 + throw httpError.notFound('client_not_found', `Client '${clientId}' not found`); 985 + } 986 + 987 + const { enabled } = req.body; 988 + if (typeof enabled !== 'boolean') { 989 + throw httpError.badRequest('invalid_enabled', 'enabled must be a boolean'); 990 + } 991 + 992 + db.setClientAccessCheck(clientId, enabled); 993 + db.logAuditEvent('client.access_check_toggle', 'admin', clientId, `Set require_access_check=${enabled}`, clientIp(req)); 994 + 995 + res.json({ message: `Access check ${enabled ? 'enabled' : 'disabled'} for ${clientId}`, require_access_check: enabled }); 996 + }); 997 + 852 998 // ===== Audit Log ===== 853 999 854 1000 /**
+14
gateway/src/routes/auth.ts
··· 11 11 import { DatabaseService } from '../services/database.js'; 12 12 import { createGatewayToken } from '../utils/hmac.js'; 13 13 import { httpError } from '../utils/errors.js'; 14 + import { checkAccess } from '../utils/access-check.js'; 14 15 15 16 /** 16 17 * Validate a redirect URI against an app's allowed callback URL. ··· 155 156 } 156 157 157 158 const result = await oauth.handleCallback(params); 159 + 160 + // Check client access rules 161 + if (app.require_access_check) { 162 + const rules = db.getClientAccessRulesForCheck(savedState.app_id); 163 + const totalRules = rules.denyRules.length + rules.originAllowRules.length + rules.globalAllowRules.length; 164 + if (totalRules > 0) { 165 + const accessResult = checkAccess(result.did, result.handle, rules); 166 + if (!accessResult.allowed) { 167 + console.log(`[Auth ACL] Access denied for ${result.handle} (${result.did}) to ${savedState.app_id}: ${accessResult.reason}`); 168 + throw httpError.forbidden('access_denied', 'You do not have access to this application'); 169 + } 170 + } 171 + } 158 172 159 173 const existingMapping = db.getUserMapping(result.did, savedState.app_id); 160 174 const userId = existingMapping?.user_id ?? null;
+83
gateway/src/routes/oidc/authorize.ts
··· 12 12 import type { PasskeyService } from '../../services/passkey.js'; 13 13 import { parseScopes, hasOpenIdScope, validateScopes } from '../../services/oidc/claims.js'; 14 14 import { isValidCodeChallengeMethod } from '../../services/oidc/pkce.js'; 15 + import { checkAccess } from '../../utils/access-check.js'; 15 16 16 17 export function createAuthorizeRouter( 17 18 db: DatabaseService, ··· 585 586 586 587 console.log(`[OIDC Passkey] Authenticated user: ${result.handle} (${result.did})`); 587 588 589 + // Check client access rules 590 + const oidcClient = db.getOIDCClient(authData.client_id); 591 + if (oidcClient?.require_access_check) { 592 + const rules = db.getClientAccessRulesForCheck(authData.client_id); 593 + const totalRules = rules.denyRules.length + rules.originAllowRules.length + rules.globalAllowRules.length; 594 + if (totalRules > 0) { 595 + const accessResult = checkAccess(result.did, result.handle, rules); 596 + if (!accessResult.allowed) { 597 + console.log(`[OIDC ACL] Access denied for ${result.handle} (${result.did}) to ${authData.client_id}: ${accessResult.reason}`); 598 + return res.status(403).json({ 599 + error: 'access_denied', 600 + error_description: 'You do not have access to this application', 601 + }); 602 + } 603 + } 604 + } 605 + 588 606 // Update the authorization code with the user's identity 589 607 db.updateAuthorizationCodeUser(auth_code, result.did, result.handle); 590 608 ··· 703 721 // Update with user info 704 722 db.updateAuthorizationCodeUser(oidcAuthCode, did, handle); 705 723 724 + // Check client access rules 725 + const oidcClient = db.getOIDCClient(authData.client_id); 726 + if (oidcClient?.require_access_check) { 727 + const rules = db.getClientAccessRulesForCheck(authData.client_id); 728 + const totalRules = rules.denyRules.length + rules.originAllowRules.length + rules.globalAllowRules.length; 729 + if (totalRules > 0) { 730 + const accessResult = checkAccess(did, handle, rules); 731 + if (!accessResult.allowed) { 732 + console.log(`[OIDC ACL] Access denied for ${handle} (${did}) to ${authData.client_id}: ${accessResult.reason}`); 733 + db.deleteOAuthState(state); 734 + return res.status(403).type('html').send(renderAccessDeniedPage(oidcClient.name || authData.client_id, res.locals.cspNonce)); 735 + } 736 + } 737 + } 738 + 706 739 // Build the redirect URL back to the original client 707 740 const clientRedirectUrl = new URL(authData.redirect_uri); 708 741 clientRedirectUrl.searchParams.set('code', oidcAuthCode); ··· 727 760 }); 728 761 729 762 return router; 763 + } 764 + 765 + /** 766 + * Render access denied page for OIDC clients 767 + */ 768 + function renderAccessDeniedPage(clientName: string, nonce?: string): string { 769 + return ` 770 + <!DOCTYPE html> 771 + <html lang="en"> 772 + <head> 773 + <meta charset="UTF-8"> 774 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 775 + <title>Not Authorized - ATAuth</title> 776 + <style${nonce ? ` nonce="${nonce}"` : ''}> 777 + * { box-sizing: border-box; margin: 0; padding: 0; } 778 + body { 779 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 780 + background: #0f172a; 781 + background-image: 782 + radial-gradient(ellipse at 20% 50%, rgba(59, 130, 246, 0.15) 0%, transparent 50%), 783 + radial-gradient(ellipse at 80% 20%, rgba(139, 92, 246, 0.12) 0%, transparent 50%); 784 + min-height: 100vh; 785 + display: flex; 786 + align-items: center; 787 + justify-content: center; 788 + padding: 20px; 789 + } 790 + .container { 791 + background: #1e293b; 792 + border: 1px solid #334155; 793 + border-radius: 16px; 794 + padding: 40px; 795 + width: 100%; 796 + max-width: 420px; 797 + box-shadow: 0 20px 60px rgba(0,0,0,0.5); 798 + text-align: center; 799 + } 800 + h1 { font-size: 22px; margin-bottom: 12px; color: #fca5a5; font-weight: 600; } 801 + .subtitle { color: #94a3b8; margin-bottom: 8px; font-size: 14px; line-height: 1.6; } 802 + .client-name { color: #e2e8f0; font-weight: 600; } 803 + </style> 804 + </head> 805 + <body> 806 + <div class="container"> 807 + <h1>Not Authorized</h1> 808 + <p class="subtitle">Your account does not have access to <span class="client-name">${clientName.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')}</span>.</p> 809 + <p class="subtitle">Contact the administrator if you believe this is an error.</p> 810 + </div> 811 + </body> 812 + </html>`; 730 813 } 731 814 732 815 /**
+96
gateway/src/services/database.ts
··· 27 27 ProxyAllowedOrigin, 28 28 ProxyAuthRequest, 29 29 ProxyAccessRule, 30 + ClientAccessRule, 30 31 } from '../types/index.js'; 31 32 32 33 export class DatabaseService { ··· 303 304 created_at INTEGER DEFAULT (unixepoch()) 304 305 ); 305 306 CREATE INDEX IF NOT EXISTS idx_proxy_access_rules_origin ON proxy_access_rules(origin_id); 307 + 308 + -- Client-level access rules (for OIDC clients and Legacy HMAC apps) 309 + CREATE TABLE IF NOT EXISTS client_access_rules ( 310 + id INTEGER PRIMARY KEY AUTOINCREMENT, 311 + client_id TEXT NOT NULL, 312 + rule_type TEXT NOT NULL CHECK(rule_type IN ('allow', 'deny')), 313 + subject_type TEXT NOT NULL CHECK(subject_type IN ('did', 'handle_pattern')), 314 + subject_value TEXT NOT NULL, 315 + description TEXT, 316 + created_at INTEGER DEFAULT (unixepoch()) 317 + ); 318 + CREATE INDEX IF NOT EXISTS idx_client_access_rules_client ON client_access_rules(client_id); 306 319 `); 320 + 321 + // Add require_access_check column to apps table if it doesn't exist 322 + if (!columnNames.includes('require_access_check')) { 323 + this.db.exec('ALTER TABLE apps ADD COLUMN require_access_check INTEGER DEFAULT 0'); 324 + } 307 325 308 326 // Audit log for admin operations 309 327 this.db.exec(` ··· 1145 1163 access_token_ttl_seconds: number; 1146 1164 refresh_token_ttl_seconds: number; 1147 1165 require_pkce: number; 1166 + require_access_check: number; 1148 1167 token_endpoint_auth_method: string; 1149 1168 created_at: string; 1150 1169 } | undefined; ··· 1163 1182 access_token_ttl_seconds: row.access_token_ttl_seconds, 1164 1183 refresh_token_ttl_seconds: row.refresh_token_ttl_seconds, 1165 1184 require_pkce: Boolean(row.require_pkce), 1185 + require_access_check: Boolean(row.require_access_check), 1166 1186 token_endpoint_auth_method: row.token_endpoint_auth_method as 'client_secret_basic' | 'client_secret_post' | 'none', 1167 1187 created_at: new Date(row.created_at), 1168 1188 }; ··· 1223 1243 access_token_ttl_seconds: number; 1224 1244 refresh_token_ttl_seconds: number; 1225 1245 require_pkce: number; 1246 + require_access_check: number; 1226 1247 token_endpoint_auth_method: string; 1227 1248 created_at: string; 1228 1249 }>; ··· 1240 1261 access_token_ttl_seconds: row.access_token_ttl_seconds, 1241 1262 refresh_token_ttl_seconds: row.refresh_token_ttl_seconds, 1242 1263 require_pkce: Boolean(row.require_pkce), 1264 + require_access_check: Boolean(row.require_access_check), 1243 1265 token_endpoint_auth_method: row.token_endpoint_auth_method as 'client_secret_basic' | 'client_secret_post' | 'none', 1244 1266 created_at: new Date(row.created_at), 1245 1267 })); ··· 1252 1274 grant_types?: string[]; 1253 1275 allowed_scopes?: string[]; 1254 1276 require_pkce?: boolean; 1277 + require_access_check?: boolean; 1255 1278 token_endpoint_auth_method?: string; 1256 1279 id_token_ttl_seconds?: number; 1257 1280 access_token_ttl_seconds?: number; ··· 1283 1306 if (updates.require_pkce !== undefined) { 1284 1307 sets.push('require_pkce = ?'); 1285 1308 values.push(updates.require_pkce ? 1 : 0); 1309 + } 1310 + if (updates.require_access_check !== undefined) { 1311 + sets.push('require_access_check = ?'); 1312 + values.push(updates.require_access_check ? 1 : 0); 1286 1313 } 1287 1314 if (updates.token_endpoint_auth_method !== undefined) { 1288 1315 sets.push('token_endpoint_auth_method = ?'); ··· 1559 1586 const stmt = this.db.prepare('SELECT id FROM proxy_allowed_origins WHERE origin = ? LIMIT 1'); 1560 1587 const row = stmt.get(origin) as { id: number } | undefined; 1561 1588 return row?.id ?? null; 1589 + } 1590 + 1591 + // Client access rule methods 1592 + 1593 + createClientAccessRule(rule: Omit<ClientAccessRule, 'id' | 'created_at'>): ClientAccessRule { 1594 + const stmt = this.db.prepare(` 1595 + INSERT INTO client_access_rules (client_id, rule_type, subject_type, subject_value, description) 1596 + VALUES (?, ?, ?, ?, ?) 1597 + `); 1598 + const result = stmt.run( 1599 + rule.client_id, rule.rule_type, rule.subject_type, 1600 + rule.subject_value, rule.description, 1601 + ); 1602 + return { 1603 + id: result.lastInsertRowid as number, 1604 + client_id: rule.client_id, 1605 + rule_type: rule.rule_type, 1606 + subject_type: rule.subject_type, 1607 + subject_value: rule.subject_value, 1608 + description: rule.description, 1609 + created_at: Math.floor(Date.now() / 1000), 1610 + }; 1611 + } 1612 + 1613 + deleteClientAccessRule(id: number): void { 1614 + this.db.prepare('DELETE FROM client_access_rules WHERE id = ?').run(id); 1615 + } 1616 + 1617 + listClientAccessRules(clientId?: string): ClientAccessRule[] { 1618 + if (clientId !== undefined) { 1619 + const stmt = this.db.prepare( 1620 + 'SELECT * FROM client_access_rules WHERE client_id = ? ORDER BY rule_type ASC, created_at ASC', 1621 + ); 1622 + return stmt.all(clientId) as ClientAccessRule[]; 1623 + } 1624 + const stmt = this.db.prepare( 1625 + 'SELECT * FROM client_access_rules ORDER BY client_id ASC, rule_type ASC, created_at ASC', 1626 + ); 1627 + return stmt.all() as ClientAccessRule[]; 1628 + } 1629 + 1630 + getClientAccessRulesForCheck(clientId: string): { 1631 + denyRules: ClientAccessRule[]; 1632 + originAllowRules: ClientAccessRule[]; 1633 + globalAllowRules: ClientAccessRule[]; 1634 + } { 1635 + const stmt = this.db.prepare( 1636 + 'SELECT * FROM client_access_rules WHERE client_id = ?', 1637 + ); 1638 + const rules = stmt.all(clientId) as ClientAccessRule[]; 1639 + 1640 + const denyRules: ClientAccessRule[] = []; 1641 + const originAllowRules: ClientAccessRule[] = []; 1642 + 1643 + for (const rule of rules) { 1644 + if (rule.rule_type === 'deny') { 1645 + denyRules.push(rule); 1646 + } else { 1647 + // All client rules are treated as client-specific (like origin-specific) 1648 + originAllowRules.push(rule); 1649 + } 1650 + } 1651 + 1652 + // No global rules for client access (all rules are client-specific) 1653 + return { denyRules, originAllowRules, globalAllowRules: [] }; 1654 + } 1655 + 1656 + setClientAccessCheck(clientId: string, enabled: boolean): void { 1657 + this.db.prepare('UPDATE apps SET require_access_check = ? WHERE id = ?').run(enabled ? 1 : 0, clientId); 1562 1658 } 1563 1659 }
+2
gateway/src/types/index.ts
··· 45 45 hmac_secret: string; 46 46 token_ttl_seconds: number; 47 47 callback_url?: string; 48 + /** Whether to enforce client-level access rules (default: false) */ 49 + require_access_check?: boolean | number; 48 50 } 49 51 50 52 export interface OAuthState {
+2
gateway/src/types/oidc.ts
··· 34 34 refresh_token_ttl_seconds: number; 35 35 require_pkce: boolean; 36 36 token_endpoint_auth_method: TokenEndpointAuthMethod; 37 + /** Whether to enforce client-level access rules (default: false = open access) */ 38 + require_access_check?: boolean; 37 39 created_at: Date; 38 40 } 39 41
+11
gateway/src/types/proxy.ts
··· 44 44 created_at: number; 45 45 } 46 46 47 + /** An access control rule for an OIDC client or Legacy HMAC app */ 48 + export interface ClientAccessRule { 49 + id: number; 50 + client_id: string; 51 + rule_type: 'allow' | 'deny'; 52 + subject_type: 'did' | 'handle_pattern'; 53 + subject_value: string; 54 + description: string | null; 55 + created_at: number; 56 + } 57 + 47 58 /** Result of an access check */ 48 59 export interface AccessCheckResult { 49 60 allowed: boolean;
+17 -8
gateway/src/utils/access-check.ts
··· 1 1 /** 2 - * Forward-Auth Proxy Access Control 2 + * Forward-Auth Proxy & Client Access Control 3 3 * 4 4 * Evaluates access rules to determine if a user (identified by DID and handle) 5 - * is allowed to access a protected service. 5 + * is allowed to access a protected service or client application. 6 6 * 7 7 * Evaluation order: 8 8 * 1. Deny rules (per-origin + global) - if any match, reject ··· 11 11 * 4. Default deny 12 12 */ 13 13 14 - import type { ProxyAccessRule, AccessCheckResult } from '../types/proxy.js'; 14 + import type { AccessCheckResult } from '../types/proxy.js'; 15 + 16 + /** Minimal rule shape required for access checking */ 17 + interface AccessRule { 18 + id: number; 19 + rule_type: 'allow' | 'deny'; 20 + subject_type: 'did' | 'handle_pattern'; 21 + subject_value: string; 22 + description: string | null; 23 + } 15 24 16 25 /** 17 26 * Match a handle against a pattern. ··· 31 40 /** 32 41 * Check if a DID or handle matches a given access rule. 33 42 */ 34 - function matchesRule(rule: ProxyAccessRule, did: string, handle: string): boolean { 43 + function matchesRule(rule: AccessRule, did: string, handle: string): boolean { 35 44 if (rule.subject_type === 'did') { 36 45 return rule.subject_value === did; 37 46 } ··· 39 48 } 40 49 41 50 /** 42 - * Evaluate access rules for a user attempting to access a protected origin. 51 + * Evaluate access rules for a user attempting to access a protected origin or client. 43 52 */ 44 53 export function checkAccess( 45 54 did: string, 46 55 handle: string, 47 56 rules: { 48 - denyRules: ProxyAccessRule[]; 49 - originAllowRules: ProxyAccessRule[]; 50 - globalAllowRules: ProxyAccessRule[]; 57 + denyRules: AccessRule[]; 58 + originAllowRules: AccessRule[]; 59 + globalAllowRules: AccessRule[]; 51 60 }, 52 61 ): AccessCheckResult { 53 62 // 1. Check deny rules (both per-origin and global)