0ee6825a01
The ORM Model.toJSON() serializes only schema fields, so the mapped client_id/scopes/redirect_uris/... that OAuthClient.get() attaches to the wrapped Resource were stripped from GET /api/oauth/client[/:id] responses. client_id came back undefined; the theta-env bootstrap then POSTed /api/oauth/client/undefined/rotate and got a 500, aborting stack bring-up whenever proxy-secrets.js lacked a usable secret. - OAuthClient.get() now emits an explicit public toJSON (client_id, name, slug, scopes, redirect_uris, allowed_groups, token_lifetime, is_valid), deliberately omitting client_secret_hash so it can't leak over the API. - OAuthClient.get() null-guards Resource.get() (which returns null, not throws) and returns a clean 404 for an unknown/undefined id instead of crashing on r.kind. - Regression tests: list/get expose client_id + hide the secret hash, the list-then-rotate bootstrap path, and unknown-id -> 4xx not 500. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
138 lines
4.6 KiB
JavaScript
138 lines
4.6 KiB
JavaScript
'use strict';
|
|
|
|
const { Resource } = require('./resource');
|
|
const bcrypt = require('bcrypt');
|
|
const crypto = require('crypto');
|
|
const conf = require('@simpleworkjs/conf');
|
|
const UUID = () => crypto.randomUUID();
|
|
|
|
const defaultLifetime = (conf.oauth && conf.oauth.token_lifetime) || {
|
|
access_token: 3600,
|
|
refresh_token: 2592000
|
|
};
|
|
|
|
class OAuthClient {
|
|
static async add(data) {
|
|
const raw_secret = crypto.randomUUID();
|
|
const client_id = crypto.randomUUID();
|
|
const client_secret_hash = await bcrypt.hash(raw_secret, 10);
|
|
|
|
// Generate a unique slug from the client name
|
|
let slug = data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'oauth-client';
|
|
// Ensure uniqueness by appending a suffix if needed
|
|
const existing = await Resource.list({ where: { slug } });
|
|
if (existing.length) slug = `${slug}-${client_id.slice(0, 8)}`;
|
|
|
|
const r = await Resource.create({
|
|
id: client_id,
|
|
kind: 'oauth',
|
|
name: data.name,
|
|
slug: slug,
|
|
description: data.description || '',
|
|
owner: data.created_by,
|
|
metadata: {
|
|
client_secret_hash,
|
|
redirect_uris: data.redirect_uris || [],
|
|
scopes: data.scopes || ['openid', 'profile', 'email', 'groups'],
|
|
allowed_groups: data.allowed_groups || [],
|
|
token_lifetime: data.token_lifetime || { ...defaultLifetime }
|
|
}
|
|
});
|
|
|
|
r._raw_secret = raw_secret;
|
|
r.client_id = client_id;
|
|
return r;
|
|
}
|
|
static async get(client_id) {
|
|
const notFound = () => {
|
|
const e = new Error('OAuthClient not found');
|
|
e.status = 404;
|
|
return e;
|
|
};
|
|
let r;
|
|
try {
|
|
r = await Resource.get(client_id);
|
|
} catch (_) {
|
|
throw notFound();
|
|
}
|
|
// Resource.get() returns null (does not throw) for a missing id —
|
|
// guard it so a bad/undefined client_id is a clean 404, not a
|
|
// "Cannot read properties of null (reading 'kind')" 500.
|
|
if (!r || r.kind !== 'oauth') throw notFound();
|
|
// Map metadata to top-level properties to satisfy routes/oauth.js without rewriting it
|
|
r.client_id = r.id;
|
|
r.client_secret_hash = r.metadata.client_secret_hash;
|
|
r.redirect_uris = r.metadata.redirect_uris || [];
|
|
r.scopes = r.metadata.scopes || ['openid', 'profile', 'email', 'groups'];
|
|
r.allowed_groups = r.metadata.allowed_groups || [];
|
|
r.token_lifetime = r.metadata.token_lifetime || { ...defaultLifetime };
|
|
// Resource has no is_valid column; validity lives in metadata (absent = valid)
|
|
r.is_valid = r.metadata.is_valid !== false;
|
|
r.verifySecret = async (secret) => bcrypt.compare(secret, r.client_secret_hash);
|
|
|
|
r.rotateSecret = async () => {
|
|
const raw_secret = crypto.randomUUID();
|
|
r.metadata.client_secret_hash = await bcrypt.hash(raw_secret, 10);
|
|
await r.update({ metadata: r.metadata });
|
|
return raw_secret;
|
|
};
|
|
|
|
// The ORM Model.toJSON() only serializes schema fields, so the mapped
|
|
// properties above (client_id, scopes, redirect_uris, …) would be
|
|
// stripped from any res.json() — that's why GET /api/oauth/client
|
|
// returned client_id: undefined and the bootstrap's rotate blew up.
|
|
// Emit the public shape explicitly. client_secret_hash is deliberately
|
|
// omitted so it never leaks over the API.
|
|
r.toJSON = function () {
|
|
return {
|
|
client_id: r.id,
|
|
id: r.id,
|
|
kind: r.kind,
|
|
name: r.name,
|
|
slug: r.slug,
|
|
owner: r.owner,
|
|
description: r.description,
|
|
redirect_uris: r.redirect_uris,
|
|
scopes: r.scopes,
|
|
allowed_groups: r.allowed_groups,
|
|
token_lifetime: r.token_lifetime,
|
|
is_valid: r.is_valid,
|
|
};
|
|
};
|
|
|
|
// proxy update to handle metadata correctly
|
|
const originalUpdate = r.update.bind(r);
|
|
r.update = async (data) => {
|
|
if (data.redirect_uris !== undefined) r.metadata.redirect_uris = data.redirect_uris;
|
|
if (data.scopes !== undefined) r.metadata.scopes = data.scopes;
|
|
if (data.allowed_groups !== undefined) r.metadata.allowed_groups = data.allowed_groups;
|
|
if (data.token_lifetime !== undefined) r.metadata.token_lifetime = data.token_lifetime;
|
|
if (data.is_valid !== undefined) r.metadata.is_valid = data.is_valid;
|
|
|
|
const updateData = { metadata: r.metadata };
|
|
if (data.name !== undefined) updateData.name = data.name;
|
|
if (data.description !== undefined) updateData.description = data.description;
|
|
|
|
return originalUpdate(updateData);
|
|
};
|
|
|
|
return r;
|
|
}
|
|
|
|
static async list() {
|
|
const resources = await Resource.list({ where: { kind: 'oauth' } });
|
|
return Promise.all(resources.map(r => this.get(r.id)));
|
|
}
|
|
|
|
static async listDetail() {
|
|
return this.list();
|
|
}
|
|
|
|
static async verifySecret(client_id, secret) {
|
|
const client = await this.get(client_id);
|
|
return client.verifySecret(secret);
|
|
}
|
|
}
|
|
|
|
module.exports = { OAuthClient };
|