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