'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 };