Skip to content

Commit b30e9e1

Browse files
committed
feat: add server.oauth config section with separate TTLs and enabled flag
OAuth endpoints now have dedicated config (enabled, accessTokenTtl, refreshTokenTtl, authCodeTtl) separate from UI JWT tokens. Disabled by default — set server.oauth.enabled: true to activate.
1 parent 24e01d1 commit b30e9e1

10 files changed

Lines changed: 75 additions & 9 deletions

File tree

docs/authentication.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ Authorization: Bearer mgm-key-abc123
188188

189189
### Option 2 — OAuth 2.0
190190

191-
The server implements RFC 8414 OAuth 2.0 Authorization Server Metadata. `jwtSecret` must be set in the config for OAuth to work.
191+
The server implements RFC 8414 OAuth 2.0 Authorization Server Metadata. `jwtSecret` must be set in the config for OAuth to work. OAuth is disabled by default; enable it with `server.oauth.enabled: true`. Token TTLs are configured separately from UI tokens via `server.oauth.accessTokenTtl` (default `1h`), `server.oauth.refreshTokenTtl` (default `7d`), and `server.oauth.authCodeTtl` (default `10m`).
192192

193193
#### Discovery manifest
194194

@@ -234,7 +234,7 @@ grant_type=client_credentials&client_id=<userId>&client_secret=<apiKey>
234234
}
235235
```
236236

237-
The `access_token` is a short-lived JWT (1 hour) signed with `jwtSecret`, with payload `{ userId, type: 'oauth_access' }`.
237+
The `access_token` is a short-lived JWT (default 1 hour, configurable via `server.oauth.accessTokenTtl`) signed with `jwtSecret`, with payload `{ userId, type: 'oauth_access' }`.
238238

239239
#### Grant type: authorization_code + PKCE
240240

docs/configuration.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ server:
4848
cookieSecure: true
4949
accessTokenTtl: "15m"
5050
refreshTokenTtl: "7d"
51+
oauth:
52+
enabled: false
53+
accessTokenTtl: "1h"
54+
refreshTokenTtl: "7d"
55+
authCodeTtl: "10m"
5156
maxFileSize: 1048576
5257
exclude: "**/vendor/**"
5358
rateLimit:
@@ -163,6 +168,16 @@ workspaces:
163168
| `rateLimit` | object | — | Rate limiting: `global` (default 600), `search` (default 120), `auth` (default 10) requests/min |
164169
| `maxFileSize` | number | `1048576` | Max file size in bytes for indexing (1 MB default). Also settable at workspace/project level |
165170
| `exclude` | string | — | Additional glob to exclude (merged with default `**/node_modules/**`, `**/dist/**`) |
171+
| `oauth` | object | (see below) | OAuth 2.0 configuration |
172+
173+
## OAuth config
174+
175+
| Field | Type | Default | Description |
176+
|-------|------|---------|-------------|
177+
| `enabled` | boolean | `false` | Enable/disable OAuth 2.0 endpoints. When `false`, discovery and all OAuth routes return 404 |
178+
| `accessTokenTtl` | string | `1h` | OAuth access token lifetime (separate from UI `accessTokenTtl`) |
179+
| `refreshTokenTtl` | string | `7d` | OAuth refresh token lifetime (separate from UI `refreshTokenTtl`) |
180+
| `authCodeTtl` | string | `10m` | Authorization code lifetime for PKCE flow |
166181

167182
## Model config
168183

graph-memory.yaml.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ server:
5151
# accessTokenTtl: "15m" # JWT access token lifetime (default: 15m)
5252
# refreshTokenTtl: "7d" # JWT refresh token lifetime (default: 7d)
5353
# maxFileSize: 1048576 # Max file size for indexing in bytes (default: 1 MB)
54+
55+
# OAuth 2.0 settings (separate from UI JWT tokens above)
56+
# oauth:
57+
# enabled: true # Enable OAuth endpoints (default: false)
58+
# accessTokenTtl: "1h" # OAuth access token lifetime (default: 1h)
59+
# refreshTokenTtl: "7d" # OAuth refresh token lifetime (default: 7d)
60+
# authCodeTtl: "10m" # Authorization code TTL for PKCE flow (default: 10m)
5461
# exclude: "**/vendor/**" # Additional glob to exclude (merged with default: **/node_modules/**, **/dist/**)
5562

5663
# Redis (optional — session store for OAuth codes/sessions + embedding cache)

src/api/rest/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,9 @@ export function createRestApp(projectManager: ProjectManager, options?: RestAppO
229229
});
230230

231231
// --- OAuth 2.0 endpoints (before auth middleware — unauthenticated) ---
232-
app.use('/', createOAuthRouter(users, serverConfig, options?.sessionStore));
232+
if (serverConfig?.oauth?.enabled !== false) {
233+
app.use('/', createOAuthRouter(users, serverConfig, options?.sessionStore));
234+
}
233235

234236
// --- Auth middleware: cookie JWT → Bearer apiKey → anonymous ---
235237
if (hasUsers) {

src/api/rest/oauth.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ import { resolveUserFromApiKey } from '@/lib/access';
55
import type { UserConfig, ServerConfig } from '@/lib/multi-config';
66
import { MemorySessionStore, type SessionStore } from '@/lib/session-store';
77

8-
const AUTH_CODE_TTL_S = 600; // 10 minutes
9-
108
function verifyPkce(codeVerifier: string, codeChallenge: string): boolean {
119
const hash = crypto.createHash('sha256').update(codeVerifier).digest();
1210
const computed = hash.toString('base64url');
@@ -47,8 +45,10 @@ export function createOAuthRouter(
4745
router.use(express.json());
4846
const hasUsers = Object.keys(users).length > 0;
4947
const jwtSecret = serverConfig?.jwtSecret;
50-
const accessTtl = serverConfig?.accessTokenTtl ?? '15m';
51-
const refreshTtl = serverConfig?.refreshTokenTtl ?? '7d';
48+
const oauthCfg = serverConfig?.oauth;
49+
const accessTtl = oauthCfg?.accessTokenTtl ?? '1h';
50+
const refreshTtl = oauthCfg?.refreshTokenTtl ?? '7d';
51+
const authCodeTtlS = oauthCfg?.authCodeTtl ? parseTtl(oauthCfg.authCodeTtl) : 600;
5252
const store = sessionStore ?? new MemorySessionStore();
5353

5454
// RFC 8414 — OAuth Authorization Server Metadata
@@ -100,7 +100,7 @@ export function createOAuthRouter(
100100
userId: payload.userId,
101101
redirectUri: redirect_uri,
102102
codeChallenge: code_challenge,
103-
}), AUTH_CODE_TTL_S);
103+
}), authCodeTtlS);
104104

105105
const callbackParams = new URLSearchParams({ code });
106106
if (state) callbackParams.set('state', state);

src/lib/multi-config.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ const rateLimitSchema = z.object({
9898
auth: z.number().int().min(0).optional(), // req/min per IP for login
9999
});
100100

101+
const oauthSchema = z.object({
102+
enabled: z.boolean().optional(),
103+
accessTokenTtl: z.string().optional(),
104+
refreshTokenTtl: z.string().optional(),
105+
authCodeTtl: z.string().optional(),
106+
});
107+
101108
const redisSchema = z.object({
102109
enabled: z.boolean().optional(),
103110
url: z.string().optional(),
@@ -125,6 +132,7 @@ const serverSchema = z.object({
125132
maxFileSize: z.number().int().positive().optional(),
126133
exclude: excludeSchema,
127134
redis: redisSchema.optional(),
135+
oauth: oauthSchema.optional(),
128136
});
129137

130138
const wsGraphConfigSchema = z.object({
@@ -226,6 +234,13 @@ export function embeddingFingerprint(model: ModelConfig): string {
226234
return `${model.name}|${model.pooling}|${model.normalize}|${model.dtype ?? ''}|${model.documentPrefix}`;
227235
}
228236

237+
export interface OAuthConfig {
238+
enabled: boolean;
239+
accessTokenTtl: string;
240+
refreshTokenTtl: string;
241+
authCodeTtl: string;
242+
}
243+
229244
export interface RateLimitConfig {
230245
global: number; // req/min per IP (0 = disabled)
231246
search: number; // req/min per IP for search/embed
@@ -252,6 +267,7 @@ export interface ServerConfig {
252267
maxFileSize: number;
253268
exclude: string[];
254269
redis: RedisConfig;
270+
oauth: OAuthConfig;
255271
}
256272

257273
export interface GraphConfig {
@@ -345,6 +361,13 @@ const EMBEDDING_DEFAULTS: EmbeddingConfig = {
345361
cacheSize: 10_000,
346362
};
347363

364+
const OAUTH_DEFAULTS: OAuthConfig = {
365+
enabled: false,
366+
accessTokenTtl: '1h',
367+
refreshTokenTtl: '7d',
368+
authCodeTtl: '10m',
369+
};
370+
348371
const RATE_LIMIT_DEFAULTS: RateLimitConfig = {
349372
global: 600,
350373
search: 120,
@@ -366,6 +389,7 @@ const SERVER_DEFAULTS: Omit<ServerConfig, 'embedding'> & { embedding: EmbeddingC
366389
maxFileSize: 1 * 1024 * 1024, // 1 MB
367390
exclude: ['**/node_modules/**', '**/dist/**'],
368391
redis: { ...REDIS_DEFAULTS },
392+
oauth: { ...OAUTH_DEFAULTS },
369393
};
370394

371395
const PROJECT_DEFAULTS = {
@@ -479,6 +503,12 @@ export function loadMultiConfig(yamlPath: string): MultiConfig {
479503
prefix: srv.redis?.prefix ?? REDIS_DEFAULTS.prefix,
480504
embeddingCacheTtl: srv.redis?.embeddingCacheTtl ?? REDIS_DEFAULTS.embeddingCacheTtl,
481505
},
506+
oauth: {
507+
enabled: srv.oauth?.enabled ?? OAUTH_DEFAULTS.enabled,
508+
accessTokenTtl: srv.oauth?.accessTokenTtl ?? OAUTH_DEFAULTS.accessTokenTtl,
509+
refreshTokenTtl: srv.oauth?.refreshTokenTtl ?? OAUTH_DEFAULTS.refreshTokenTtl,
510+
authCodeTtl: srv.oauth?.authCodeTtl ?? OAUTH_DEFAULTS.authCodeTtl,
511+
},
482512
};
483513

484514
// Users
@@ -649,6 +679,7 @@ export function defaultConfig(projectDir: string): MultiConfig {
649679
maxFileSize: SERVER_DEFAULTS.maxFileSize,
650680
exclude: [...SERVER_DEFAULTS.exclude],
651681
redis: { ...REDIS_DEFAULTS },
682+
oauth: { ...OAUTH_DEFAULTS },
652683
};
653684

654685
const graphConfigs = {} as Record<GraphName, GraphConfig>;

src/tests/access.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ function makeServerConfig(overrides?: Partial<ServerConfig>): ServerConfig {
4040
rateLimit: { global: 200, search: 60, auth: 10 },
4141
maxFileSize: 1048576,
4242
redis: { enabled: false, url: 'redis://localhost:6379', prefix: 'mgm:', embeddingCacheTtl: '30d' },
43+
oauth: { enabled: true, accessTokenTtl: '1h', refreshTokenTtl: '7d', authCodeTtl: '10m' },
4344
...overrides,
4445
};
4546
}

src/tests/oauth.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ const USERS: Record<string, UserConfig> = {
2222
bob: { name: 'Bob', email: 'bob@example.com', apiKey: 'mgm-key-bob' },
2323
};
2424

25-
const SERVER_CONFIG = { jwtSecret: SECRET, accessTokenTtl: '1h', refreshTokenTtl: '7d' } as any;
25+
const SERVER_CONFIG = {
26+
jwtSecret: SECRET,
27+
accessTokenTtl: '15m', refreshTokenTtl: '7d',
28+
oauth: { enabled: true, accessTokenTtl: '1h', refreshTokenTtl: '7d', authCodeTtl: '10m' },
29+
} as any;
2630

2731
function buildApp(users: Record<string, UserConfig>, serverConfig?: any): express.Express {
2832
const app = express();

src/tests/rest-api-gaps.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,7 @@ describe('CORS credentials', () => {
598598
defaultAccess: 'rw',
599599
accessTokenTtl: '15m', refreshTokenTtl: '7d', rateLimit: { global: 0, search: 0, auth: 0 }, maxFileSize: 1048576, exclude: [],
600600
redis: { enabled: false, url: 'redis://localhost:6379', prefix: 'mgm:', embeddingCacheTtl: '30d' },
601+
oauth: { enabled: true, accessTokenTtl: '1h', refreshTokenTtl: '7d', authCodeTtl: '10m' },
601602
},
602603
});
603604
const res = await request(app)

src/tests/rest-api.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,7 @@ describe('REST API — Auth & ACL', () => {
842842
access: { admin: 'rw' },
843843
accessTokenTtl: '15m', refreshTokenTtl: '7d', rateLimit: { global: 0, search: 0, auth: 0 }, maxFileSize: 1048576, exclude: [],
844844
redis: { enabled: false, url: 'redis://localhost:6379', prefix: 'mgm:', embeddingCacheTtl: '30d' },
845+
oauth: { enabled: true, accessTokenTtl: '1h', refreshTokenTtl: '7d', authCodeTtl: '10m' },
845846
},
846847
users: {
847848
admin: { name: 'Admin', email: 'admin@test.com', apiKey: 'key-admin' },
@@ -891,6 +892,7 @@ describe('REST API — Auth & ACL', () => {
891892
access: { reader: 'r' },
892893
accessTokenTtl: '15m', refreshTokenTtl: '7d', rateLimit: { global: 0, search: 0, auth: 0 }, maxFileSize: 1048576, exclude: [],
893894
redis: { enabled: false, url: 'redis://localhost:6379', prefix: 'mgm:', embeddingCacheTtl: '30d' },
895+
oauth: { enabled: true, accessTokenTtl: '1h', refreshTokenTtl: '7d', authCodeTtl: '10m' },
894896
},
895897
users: {
896898
reader: { name: 'Reader', email: 'reader@test.com', apiKey: 'key-reader' },
@@ -959,6 +961,7 @@ describe('REST API — Embedding API', () => {
959961
defaultAccess: 'rw',
960962
accessTokenTtl: '15m', refreshTokenTtl: '7d', rateLimit: { global: 0, search: 0, auth: 0 }, maxFileSize: 1048576, exclude: [],
961963
redis: { enabled: false, url: 'redis://localhost:6379', prefix: 'mgm:', embeddingCacheTtl: '30d' },
964+
oauth: { enabled: true, accessTokenTtl: '1h', refreshTokenTtl: '7d', authCodeTtl: '10m' },
962965
},
963966
embeddingApiModelNames: { default: EMBED_MODEL_NAME, code: EMBED_MODEL_NAME },
964967
});
@@ -1029,6 +1032,7 @@ describe('REST API — Embedding API', () => {
10291032
defaultAccess: 'rw',
10301033
accessTokenTtl: '15m', refreshTokenTtl: '7d', rateLimit: { global: 0, search: 0, auth: 0 }, maxFileSize: 1048576, exclude: [],
10311034
redis: { enabled: false, url: 'redis://localhost:6379', prefix: 'mgm:', embeddingCacheTtl: '30d' },
1035+
oauth: { enabled: true, accessTokenTtl: '1h', refreshTokenTtl: '7d', authCodeTtl: '10m' },
10321036
},
10331037
});
10341038
const res = await request(noEmbedApp).post('/api/embed').send({ texts: ['test'] });
@@ -1073,6 +1077,7 @@ describe('REST API — JWT Cookie Auth', () => {
10731077
jwtSecret: JWT_SECRET,
10741078
accessTokenTtl: '15m', refreshTokenTtl: '7d', rateLimit: { global: 0, search: 0, auth: 0 }, maxFileSize: 1048576, exclude: [],
10751079
redis: { enabled: false, url: 'redis://localhost:6379', prefix: 'mgm:', embeddingCacheTtl: '30d' },
1080+
oauth: { enabled: true, accessTokenTtl: '1h', refreshTokenTtl: '7d', authCodeTtl: '10m' },
10761081
},
10771082
users: {
10781083
admin: { name: 'Admin', email: 'admin@test.com', apiKey: 'key-admin', passwordHash: adminPasswordHash },

0 commit comments

Comments
 (0)