auth-core-m2m.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. /**
  2. * briven-engine M2M client credentials.
  3. *
  4. * Dashboard (session + project admin):
  5. * GET/POST /v1/auth-core/projects/:projectId/m2m/clients
  6. * DELETE /v1/auth-core/projects/:projectId/m2m/clients/:clientId
  7. *
  8. * Public token endpoint (OAuth2 client_credentials):
  9. * POST /v1/auth-core/oauth/token
  10. */
  11. import { Hono } from 'hono';
  12. import { requireAuthCoreProject } from '../middleware/auth-core-guard.js';
  13. import { BRIVEN_ENGINE_ID } from '../services/auth-core/engine.js';
  14. import {
  15. createM2mClient,
  16. isM2mRole,
  17. issueM2mToken,
  18. listM2mClients,
  19. revokeM2mClient,
  20. type M2mRole,
  21. } from '../services/auth-core/m2m.js';
  22. import type { AppEnv } from '../types/app-env.js';
  23. import type { User } from '../middleware/session.js';
  24. export const authCoreM2mRouter = new Hono<AppEnv>();
  25. // ─── Dashboard: manage clients ───────────────────────────────────────
  26. authCoreM2mRouter.use(
  27. '/v1/auth-core/projects/:projectId/m2m/clients',
  28. ...requireAuthCoreProject('admin'),
  29. );
  30. authCoreM2mRouter.use(
  31. '/v1/auth-core/projects/:projectId/m2m/clients/*',
  32. ...requireAuthCoreProject('admin'),
  33. );
  34. authCoreM2mRouter.get(
  35. '/v1/auth-core/projects/:projectId/m2m/clients',
  36. async (c) => {
  37. const projectId = c.req.param('projectId');
  38. try {
  39. const clients = await listM2mClients(projectId);
  40. return c.json({
  41. engine: BRIVEN_ENGINE_ID,
  42. projectId,
  43. clients: clients.map((cl) => ({
  44. id: cl.id,
  45. clientId: cl.clientId,
  46. name: cl.name,
  47. role: cl.role,
  48. hint: `…${cl.secretSuffix}`,
  49. revokedAt: cl.revokedAt,
  50. lastUsedAt: cl.lastUsedAt,
  51. createdAt: cl.createdAt,
  52. })),
  53. });
  54. } catch (err) {
  55. return c.json(
  56. {
  57. engine: BRIVEN_ENGINE_ID,
  58. code: 'list_failed',
  59. message: err instanceof Error ? err.message : String(err),
  60. },
  61. 500,
  62. );
  63. }
  64. },
  65. );
  66. authCoreM2mRouter.post(
  67. '/v1/auth-core/projects/:projectId/m2m/clients',
  68. async (c) => {
  69. const projectId = c.req.param('projectId');
  70. let body: { name?: string; role?: string } = {};
  71. try {
  72. body = await c.req.json();
  73. } catch {
  74. body = {};
  75. }
  76. if (!body.name?.trim()) {
  77. return c.json(
  78. { engine: BRIVEN_ENGINE_ID, code: 'name_required', message: 'name required' },
  79. 400,
  80. );
  81. }
  82. const role: M2mRole =
  83. body.role && isM2mRole(body.role) ? body.role : 'developer';
  84. const user = c.get('user') as User | null;
  85. try {
  86. const created = await createM2mClient({
  87. projectId,
  88. name: body.name,
  89. role,
  90. createdBy: user?.id ?? null,
  91. });
  92. return c.json({
  93. engine: BRIVEN_ENGINE_ID,
  94. projectId,
  95. client: {
  96. id: created.client.id,
  97. clientId: created.client.clientId,
  98. name: created.client.name,
  99. role: created.client.role,
  100. hint: `…${created.client.secretSuffix}`,
  101. /** Only once */
  102. clientSecret: created.clientSecret,
  103. },
  104. note: 'Copy client_id and client_secret now — secret is not shown again.',
  105. tokenUrl: `${(process.env.BRIVEN_API_ORIGIN ?? 'https://api.briven.tech').replace(/\/$/, '')}/v1/auth-core/oauth/token`,
  106. });
  107. } catch (err) {
  108. return c.json(
  109. {
  110. engine: BRIVEN_ENGINE_ID,
  111. code: 'create_failed',
  112. message: err instanceof Error ? err.message : String(err),
  113. },
  114. 400,
  115. );
  116. }
  117. },
  118. );
  119. authCoreM2mRouter.delete(
  120. '/v1/auth-core/projects/:projectId/m2m/clients/:clientId',
  121. async (c) => {
  122. const projectId = c.req.param('projectId');
  123. const clientId = c.req.param('clientId');
  124. try {
  125. await revokeM2mClient(projectId, clientId);
  126. return c.json({
  127. engine: BRIVEN_ENGINE_ID,
  128. ok: true,
  129. projectId,
  130. clientId,
  131. });
  132. } catch (err) {
  133. return c.json(
  134. {
  135. engine: BRIVEN_ENGINE_ID,
  136. code: 'revoke_failed',
  137. message: err instanceof Error ? err.message : String(err),
  138. },
  139. 404,
  140. );
  141. }
  142. },
  143. );
  144. // ─── Public: OAuth2 token endpoint ───────────────────────────────────
  145. /**
  146. * POST /v1/auth-core/oauth/token
  147. * grant_type=client_credentials
  148. * Accepts JSON or form body; optional HTTP Basic client_id:client_secret.
  149. */
  150. authCoreM2mRouter.post('/v1/auth-core/oauth/token', async (c) => {
  151. // Abuse throttle (credential stuffing) — SuperTokens-style protect token endpoint.
  152. try {
  153. const { getRedis } = await import('../lib/redis.js');
  154. const redis = getRedis();
  155. const ip =
  156. c.req.header('cf-connecting-ip')?.trim() ||
  157. c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ||
  158. c.req.header('x-real-ip')?.trim() ||
  159. 'unknown';
  160. if (redis) {
  161. const windowSec = 60;
  162. const max = 30;
  163. const key = `rl:m2m:token:${ip}`;
  164. const n = await redis.incr(key);
  165. if (n === 1) await redis.expire(key, windowSec);
  166. if (n > max) {
  167. return c.json(
  168. {
  169. error: 'rate_limited',
  170. error_description: 'too many token requests — try again shortly',
  171. engine: BRIVEN_ENGINE_ID,
  172. },
  173. 429,
  174. );
  175. }
  176. }
  177. } catch {
  178. /* fail open if redis unavailable */
  179. }
  180. let clientId = '';
  181. let clientSecret = '';
  182. let grantType = '';
  183. const auth = c.req.header('authorization');
  184. if (auth?.startsWith('Basic ')) {
  185. try {
  186. const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
  187. const colon = decoded.indexOf(':');
  188. if (colon > 0) {
  189. clientId = decoded.slice(0, colon);
  190. clientSecret = decoded.slice(colon + 1);
  191. }
  192. } catch {
  193. // ignore — body may still provide credentials
  194. }
  195. }
  196. const ct = c.req.header('content-type') ?? '';
  197. if (ct.includes('application/x-www-form-urlencoded')) {
  198. const form = await c.req.parseBody();
  199. grantType = String(form.grant_type ?? '');
  200. if (!clientId) clientId = String(form.client_id ?? '');
  201. if (!clientSecret) clientSecret = String(form.client_secret ?? '');
  202. } else {
  203. let body: {
  204. grant_type?: string;
  205. client_id?: string;
  206. client_secret?: string;
  207. } = {};
  208. try {
  209. body = await c.req.json();
  210. } catch {
  211. body = {};
  212. }
  213. grantType = body.grant_type ?? '';
  214. if (!clientId) clientId = body.client_id ?? '';
  215. if (!clientSecret) clientSecret = body.client_secret ?? '';
  216. }
  217. if (grantType !== 'client_credentials') {
  218. return c.json(
  219. {
  220. error: 'unsupported_grant_type',
  221. error_description: 'only client_credentials is supported',
  222. engine: BRIVEN_ENGINE_ID,
  223. },
  224. 400,
  225. );
  226. }
  227. const result = await issueM2mToken({ clientId, clientSecret });
  228. if (!result.ok) {
  229. return c.json(
  230. {
  231. error: result.code,
  232. error_description: result.message,
  233. engine: BRIVEN_ENGINE_ID,
  234. },
  235. 401,
  236. );
  237. }
  238. return c.json({
  239. access_token: result.accessToken,
  240. token_type: result.tokenType,
  241. expires_in: result.expiresIn,
  242. engine: BRIVEN_ENGINE_ID,
  243. project_id: result.projectId,
  244. client_id: result.clientId,
  245. role: result.role,
  246. });
  247. });