mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
feat(oauth): add client_credentials grant for machine clients and fix PlaceAvatar stale image retry
- Add OAuth 2.0 client_credentials flow so AI agents and scripts can obtain tokens directly via client_id + client_secret without any browser interaction - New DB column allows_client_credentials on oauth_clients; machine clients skip redirect URI requirement and are forced confidential - New issueClientCredentialsToken() issues access-only tokens (no refresh token, RFC 6749 §4.4) - UI: "Machine client" checkbox in create-client modal, hides redirect URI field, shows indigo badge on existing machine clients - Advertise client_credentials in OAuth discovery document - 8 new integration tests (OAUTH-CC-001–008) - i18n: 4 new keys across all 15 languages - Fix PlaceAvatar: re-fetch photo via API on image_url load failure before falling back to initials - Update MCP wiki docs with new Option B machine client setup guide
This commit is contained in:
+1
-1
@@ -397,7 +397,7 @@ export function createApp(): express.Application {
|
||||
revocation_endpoint: `${base}/oauth/revoke`,
|
||||
registration_endpoint: `${base}/oauth/register`,
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
|
||||
scopes_supported: ALL_SCOPES,
|
||||
|
||||
@@ -2229,6 +2229,14 @@ function runMigrations(db: Database.Database): void {
|
||||
db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`)
|
||||
db.exec(`UPDATE app_settings SET value = '${process.env.APP_VERSION || '3.0.15'}' WHERE key = 'app_version'`);
|
||||
},
|
||||
// Migration: OAuth 2.0 client_credentials grant — allow user-owned confidential
|
||||
// clients to skip the browser consent flow entirely and obtain tokens directly
|
||||
// via client_id + client_secret. Flag is immutable after creation so existing
|
||||
// authorization-code clients are not silently upgraded.
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE oauth_clients ADD COLUMN allows_client_credentials INTEGER NOT NULL DEFAULT 0'); }
|
||||
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
consumeAuthCode,
|
||||
saveConsent,
|
||||
issueTokens,
|
||||
issueClientCredentialsToken,
|
||||
refreshTokens,
|
||||
revokeToken,
|
||||
verifyPKCE,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
AuthorizeParams,
|
||||
} from '../services/oauthService';
|
||||
import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
|
||||
import { getMcpSafeUrl } from '../services/notifications';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal in-file rate limiter (same pattern as auth.ts)
|
||||
@@ -151,6 +153,48 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
|
||||
return res.json(result.tokens);
|
||||
}
|
||||
|
||||
// ---- client_credentials grant ----
|
||||
if (grant_type === 'client_credentials') {
|
||||
if (!client_secret) {
|
||||
return res.status(401).json({ error: 'invalid_client', error_description: 'client_secret is required for client_credentials grant' });
|
||||
}
|
||||
|
||||
const client = authenticateClient(client_id, client_secret);
|
||||
if (!client) {
|
||||
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
|
||||
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
}
|
||||
|
||||
// Public clients and DCR-anonymous clients are ineligible for client_credentials.
|
||||
if (client.is_public || !client.allows_client_credentials || client.user_id == null) {
|
||||
writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip });
|
||||
return res.status(400).json({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' });
|
||||
}
|
||||
|
||||
// Scope: use requested subset or fall back to all allowed scopes.
|
||||
const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
|
||||
let grantedScopes: string[];
|
||||
if (body.scope) {
|
||||
const requested = body.scope.split(' ').filter(Boolean);
|
||||
const invalid = requested.filter(s => !allowedScopes.includes(s));
|
||||
if (invalid.length > 0) {
|
||||
return res.status(400).json({ error: 'invalid_scope', error_description: `Scopes not allowed for this client: ${invalid.join(', ')}` });
|
||||
}
|
||||
grantedScopes = requested;
|
||||
} else {
|
||||
grantedScopes = allowedScopes;
|
||||
}
|
||||
|
||||
// Audience: honour RFC 8707 resource param; default to the MCP endpoint so the
|
||||
// token passes audience binding in mcp/index.ts without extra configuration.
|
||||
const audience = resource ? resource.replace(/\/+$/, '') : `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
|
||||
|
||||
const tokens = issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience);
|
||||
writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip });
|
||||
return res.json(tokens);
|
||||
}
|
||||
|
||||
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
|
||||
});
|
||||
|
||||
@@ -327,13 +371,14 @@ oauthApiRouter.get('/clients', authenticate, (req: Request, res: Response) => {
|
||||
oauthApiRouter.post('/clients', requireCookieAuth, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
|
||||
const { user } = req as AuthRequest;
|
||||
const { name, redirect_uris, allowed_scopes } = req.body as {
|
||||
const { name, redirect_uris, allowed_scopes, allows_client_credentials } = req.body as {
|
||||
name: string;
|
||||
redirect_uris: string[];
|
||||
redirect_uris?: string[];
|
||||
allowed_scopes: string[];
|
||||
allows_client_credentials?: boolean;
|
||||
};
|
||||
|
||||
const result = createOAuthClient(user.id, name, redirect_uris, allowed_scopes, getClientIp(req));
|
||||
const result = createOAuthClient(user.id, name, redirect_uris ?? [], allowed_scopes, getClientIp(req), { allowsClientCredentials: allows_client_credentials });
|
||||
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
||||
return res.status(201).json(result);
|
||||
});
|
||||
|
||||
@@ -60,6 +60,7 @@ interface OAuthClientRow {
|
||||
created_at: string;
|
||||
is_public: number; // 0 | 1 (SQLite boolean)
|
||||
created_via: string; // 'settings_ui' | 'browser-registration'
|
||||
allows_client_credentials: number; // 0 | 1
|
||||
}
|
||||
|
||||
interface OAuthTokenRow {
|
||||
@@ -106,11 +107,12 @@ function generateRefreshToken(): string {
|
||||
|
||||
export function listOAuthClients(userId: number): Record<string, unknown>[] {
|
||||
const rows = db.prepare(
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
|
||||
).all(userId) as OAuthClientRow[];
|
||||
return rows.map(r => ({
|
||||
...r,
|
||||
is_public: Boolean(r.is_public),
|
||||
allows_client_credentials: Boolean(r.allows_client_credentials),
|
||||
redirect_uris: JSON.parse(r.redirect_uris),
|
||||
allowed_scopes: JSON.parse(r.allowed_scopes),
|
||||
}));
|
||||
@@ -132,11 +134,12 @@ export function createOAuthClient(
|
||||
redirectUris: string[],
|
||||
allowedScopes: string[],
|
||||
ip?: string | null,
|
||||
options?: { isPublic?: boolean; createdVia?: string },
|
||||
options?: { isPublic?: boolean; createdVia?: string; allowsClientCredentials?: boolean },
|
||||
): { error?: string; status?: number; client?: Record<string, unknown> } {
|
||||
if (!name?.trim()) return { error: 'Name is required', status: 400 };
|
||||
if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 };
|
||||
if (!redirectUris || redirectUris.length === 0) return { error: 'At least one redirect URI is required', status: 400 };
|
||||
const isMachineClient = Boolean(options?.allowsClientCredentials);
|
||||
if (!isMachineClient && (!redirectUris || redirectUris.length === 0)) return { error: 'At least one redirect URI is required', status: 400 };
|
||||
if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 };
|
||||
|
||||
for (const uri of redirectUris) {
|
||||
@@ -164,7 +167,8 @@ export function createOAuthClient(
|
||||
if (count >= 500) return { error: 'server_error', status: 503 };
|
||||
}
|
||||
|
||||
const isPublic = options?.isPublic ?? false;
|
||||
// Machine clients (client_credentials) must always be confidential — ignore isPublic for them.
|
||||
const isPublic = isMachineClient ? false : (options?.isPublic ?? false);
|
||||
const createdVia = options?.createdVia ?? 'settings_ui';
|
||||
const id = randomUUID();
|
||||
const clientId = randomUUID();
|
||||
@@ -173,14 +177,14 @@ export function createOAuthClient(
|
||||
const secretHash = rawSecret ? hashToken(rawSecret) : randomBytes(32).toString('hex');
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia);
|
||||
'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via, allows_client_credentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia, isMachineClient ? 1 : 0);
|
||||
|
||||
const row = db.prepare(
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE id = ?'
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE id = ?'
|
||||
).get(id) as OAuthClientRow;
|
||||
|
||||
writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic }, ip });
|
||||
writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic, allows_client_credentials: isMachineClient }, ip });
|
||||
|
||||
return {
|
||||
client: {
|
||||
@@ -192,6 +196,7 @@ export function createOAuthClient(
|
||||
allowed_scopes: JSON.parse(row.allowed_scopes),
|
||||
created_at: row.created_at,
|
||||
is_public: Boolean(row.is_public),
|
||||
allows_client_credentials: Boolean(row.allows_client_credentials),
|
||||
created_via: row.created_via,
|
||||
// client_secret only present for confidential clients — shown once, not stored in plain text
|
||||
...(rawSecret ? { client_secret: rawSecret } : {}),
|
||||
@@ -330,6 +335,43 @@ export function issueTokens(
|
||||
};
|
||||
}
|
||||
|
||||
// Issues an access token only — no refresh token (RFC 6749 §4.4.3).
|
||||
// Used exclusively for the client_credentials grant. A random opaque hash is
|
||||
// stored in refresh_token_hash to satisfy the NOT NULL/UNIQUE constraint; it
|
||||
// can never be presented as a valid refresh token (same precedent as public
|
||||
// client secret hashes stored in client_secret_hash).
|
||||
export function issueClientCredentialsToken(
|
||||
clientId: string,
|
||||
userId: number,
|
||||
scopes: string[],
|
||||
audience: string,
|
||||
): {
|
||||
access_token: string;
|
||||
token_type: 'Bearer';
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
} {
|
||||
const rawAccess = generateAccessToken();
|
||||
const accessHash = hashToken(rawAccess);
|
||||
const placeholderHash = randomBytes(32).toString('hex');
|
||||
|
||||
const now = new Date();
|
||||
const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO oauth_tokens
|
||||
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, parent_token_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(clientId, userId, accessHash, placeholderHash, JSON.stringify(scopes), audience, accessExpiry.toISOString(), now.toISOString(), null);
|
||||
|
||||
return {
|
||||
access_token: rawAccess,
|
||||
token_type: 'Bearer',
|
||||
expires_in: ACCESS_TOKEN_TTL_S,
|
||||
scope: scopes.join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token verification (used by MCP handler on every request)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -63,7 +63,7 @@ import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { createOAuthClient, createAuthCode } from '../../src/services/oauthService';
|
||||
import { createOAuthClient, createAuthCode, getUserByAccessToken } from '../../src/services/oauthService';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
@@ -1285,4 +1285,141 @@ describe('C3 — Refresh token replay detection', () => {
|
||||
expect(t4.status).toBe(400);
|
||||
expect(t4.body.error).toBe('invalid_grant');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// POST /oauth/token — client_credentials grant
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /oauth/token — client_credentials grant', () => {
|
||||
it('OAUTH-CC-001 — happy path: issues access token with no refresh_token', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.access_token).toBeDefined();
|
||||
expect(res.body.token_type).toBe('Bearer');
|
||||
expect(typeof res.body.expires_in).toBe('number');
|
||||
expect(res.body.scope).toBe('trips:read');
|
||||
expect(res.body.refresh_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('OAUTH-CC-002 — issued token resolves to the client owner user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const info = getUserByAccessToken(res.body.access_token);
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.user.id).toBe(user.id);
|
||||
expect(info!.scopes).toEqual(['trips:read']);
|
||||
});
|
||||
|
||||
it('OAUTH-CC-003 — wrong client_secret returns 401 invalid_client', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: 'trekcs_wrong',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe('invalid_client');
|
||||
});
|
||||
|
||||
it('OAUTH-CC-004 — missing client_secret returns 401 invalid_client', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: r.client!.client_id,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe('invalid_client');
|
||||
});
|
||||
|
||||
it('OAUTH-CC-005 — non-machine client returns 400 unauthorized_client', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'BrowserApp', ['https://app.example.com/cb'], ['trips:read']);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('unauthorized_client');
|
||||
});
|
||||
|
||||
it('OAUTH-CC-006 — scope narrowing: requested subset is honoured', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read', 'places:read'], null, { allowsClientCredentials: true });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
scope: 'trips:read',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.scope).toBe('trips:read');
|
||||
});
|
||||
|
||||
it('OAUTH-CC-007 — scope outside allowed_scopes returns 400 invalid_scope', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
scope: 'places:write',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('invalid_scope');
|
||||
});
|
||||
|
||||
it('OAUTH-CC-008 — createOAuthClient with allowsClientCredentials succeeds without redirect URIs', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
|
||||
|
||||
expect(r.error).toBeUndefined();
|
||||
expect(r.client).toBeDefined();
|
||||
expect(r.client!.allows_client_credentials).toBe(true);
|
||||
expect((r.client!.redirect_uris as string[]).length).toBe(0);
|
||||
expect(r.client!.client_secret).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user