|
27 | 27 | */ |
28 | 28 |
|
29 | 29 | import type { Plugin, PluginContext } from '@objectstack/core'; |
| 30 | +import { createHmac, randomUUID } from 'node:crypto'; |
30 | 31 | import type { KernelManager } from './kernel-manager.js'; |
31 | 32 | import type { EnvironmentDriverRegistry } from './environment-registry.js'; |
32 | 33 |
|
33 | 34 | const AUTH_PREFIX = '/api/v1/auth'; |
34 | 35 |
|
| 36 | +/** |
| 37 | + * HMAC-SHA256 + base64 + percent-encode the resulting `value.signature` |
| 38 | + * payload — replicates `serializeSignedCookie` from `better-call` so the |
| 39 | + * env's better-auth `getSignedCookie` validator accepts the cookie we |
| 40 | + * write here (without pulling better-call into runtime's deps). |
| 41 | + */ |
| 42 | +function signSessionCookieValue(rawToken: string, secret: string): string { |
| 43 | + const signature = createHmac('sha256', secret).update(rawToken).digest('base64'); |
| 44 | + return encodeURIComponent(`${rawToken}.${signature}`); |
| 45 | +} |
| 46 | + |
| 47 | +/** |
| 48 | + * Serialize a Set-Cookie header value matching better-auth's session-cookie |
| 49 | + * attributes. Attributes come from `authCookies.sessionToken.attributes`. |
| 50 | + */ |
| 51 | +function buildSetCookieHeader( |
| 52 | + name: string, |
| 53 | + encodedValue: string, |
| 54 | + attrs: Record<string, any> | undefined, |
| 55 | + maxAgeSec: number, |
| 56 | +): string { |
| 57 | + const parts: string[] = [`${name}=${encodedValue}`]; |
| 58 | + const a = attrs ?? {}; |
| 59 | + if (a.path) parts.push(`Path=${a.path}`); else parts.push('Path=/'); |
| 60 | + if (Number.isFinite(maxAgeSec) && maxAgeSec > 0) parts.push(`Max-Age=${Math.floor(maxAgeSec)}`); |
| 61 | + if (a.domain) parts.push(`Domain=${a.domain}`); |
| 62 | + if (a.sameSite) { |
| 63 | + const ss = String(a.sameSite); |
| 64 | + parts.push(`SameSite=${ss.charAt(0).toUpperCase() + ss.slice(1)}`); |
| 65 | + } else { |
| 66 | + parts.push('SameSite=Lax'); |
| 67 | + } |
| 68 | + if (a.secure) parts.push('Secure'); |
| 69 | + if (a.httpOnly !== false) parts.push('HttpOnly'); |
| 70 | + if (a.partitioned) parts.push('Partitioned'); |
| 71 | + return parts.join('; '); |
| 72 | +} |
| 73 | + |
35 | 74 | // AuthCapableManager interface removed — unused (pickHandler uses duck typing on `any`). |
36 | 75 |
|
37 | 76 | function pickHandler(svc: any): ((req: Request) => Promise<Response>) | undefined { |
@@ -162,6 +201,154 @@ export class AuthProxyPlugin implements Plugin { |
162 | 201 | } |
163 | 202 | } |
164 | 203 |
|
| 204 | + // ── sso-handoff-issue ───────────────────────────── |
| 205 | + // POST /api/v1/auth/sso-handoff-issue |
| 206 | + // |
| 207 | + // Called server-to-server by cloud's `sso_as_owner` |
| 208 | + // action. Cloud control plane (createCloudStack) has |
| 209 | + // no kernel-manager, so it cannot write into the env's |
| 210 | + // better-auth verification table itself — this endpoint |
| 211 | + // does it locally where the env kernel lives. |
| 212 | + // |
| 213 | + // Auth: `Authorization: Bearer <OS_CLOUD_API_KEY>` must |
| 214 | + // match `process.env.OS_CLOUD_API_KEY` on the env |
| 215 | + // runtime. The same secret is shared between cloud and |
| 216 | + // every env runtime (cloud uses it to verify env → |
| 217 | + // cloud calls; we reuse it for the reverse direction). |
| 218 | + // |
| 219 | + // Body: { email, name?, by?, envId? } — payload that |
| 220 | + // sso-exchange will JSON.parse from the verification |
| 221 | + // value. Returns { token, expiresAt, ttlSec }. |
| 222 | + if (c.req.method === 'POST' && subPath === 'sso-handoff-issue') { |
| 223 | + try { |
| 224 | + const expected = (process.env.OS_CLOUD_API_KEY ?? '').trim(); |
| 225 | + if (!expected) { |
| 226 | + return c.json({ error: 'sso_handoff_disabled', reason: 'OS_CLOUD_API_KEY unset on env runtime' }, 503); |
| 227 | + } |
| 228 | + const authz = c.req.header('authorization') ?? ''; |
| 229 | + const provided = authz.toLowerCase().startsWith('bearer ') |
| 230 | + ? authz.slice(7).trim() |
| 231 | + : ''; |
| 232 | + if (!provided || provided !== expected) { |
| 233 | + return c.json({ error: 'unauthorized' }, 401); |
| 234 | + } |
| 235 | + |
| 236 | + if (typeof authSvc?.getAuthContext !== 'function') { |
| 237 | + return c.json({ error: 'auth_service_unavailable' }, 503); |
| 238 | + } |
| 239 | + const handoffAuthCtx: any = await authSvc.getAuthContext(); |
| 240 | + const internal = handoffAuthCtx?.internalAdapter; |
| 241 | + if (!internal?.createVerificationValue) { |
| 242 | + return c.json({ error: 'verification_api_unavailable' }, 503); |
| 243 | + } |
| 244 | + |
| 245 | + let body: any = {}; |
| 246 | + try { body = await c.req.json(); } catch { body = {}; } |
| 247 | + const email = String(body?.email ?? '').toLowerCase().trim(); |
| 248 | + if (!email) return c.json({ error: 'email_required' }, 400); |
| 249 | + const name = body?.name == null ? null : String(body.name); |
| 250 | + const by = body?.by == null ? 'service' : String(body.by); |
| 251 | + const envIdInBody = body?.envId == null ? null : String(body.envId); |
| 252 | + |
| 253 | + const handoff = randomUUID().replace(/-/g, '') |
| 254 | + + randomUUID().replace(/-/g, ''); |
| 255 | + const ttlSec = 60; |
| 256 | + const expiresAt = new Date(Date.now() + ttlSec * 1000); |
| 257 | + await internal.createVerificationValue({ |
| 258 | + identifier: `sso-handoff:${handoff}`, |
| 259 | + value: JSON.stringify({ email, name, by, envId: envIdInBody ?? environmentId }), |
| 260 | + expiresAt, |
| 261 | + }); |
| 262 | + return c.json({ |
| 263 | + token: handoff, |
| 264 | + expiresAt: expiresAt.toISOString(), |
| 265 | + ttlSec, |
| 266 | + }); |
| 267 | + } catch (err: any) { |
| 268 | + ctx.logger?.error?.('[AuthProxyPlugin] sso-handoff-issue failed', err instanceof Error ? err : new Error(String(err))); |
| 269 | + return c.json({ error: 'sso_handoff_issue_failed', message: String(err?.message ?? err) }, 500); |
| 270 | + } |
| 271 | + } |
| 272 | + |
| 273 | + // ── sso-exchange ─────────────────────────────────── |
| 274 | + // GET /api/v1/auth/sso-exchange?token=<handoff>&next=/ |
| 275 | + // |
| 276 | + // Consumes a single-use handoff token previously written |
| 277 | + // by the cloud `sso_as_owner` action (via the |
| 278 | + // sso-handoff-issue endpoint above), finds-or-creates |
| 279 | + // the user in the env by email, mints a fresh better-auth |
| 280 | + // session, sets the signed session cookie and 302s to |
| 281 | + // `next`. If the user has no credential account yet, we |
| 282 | + // redirect to /_console/system/profile?recovery_needed=true |
| 283 | + // so they can configure a disaster-recovery local password. |
| 284 | + if (c.req.method === 'GET' && subPath === 'sso-exchange') { |
| 285 | + try { |
| 286 | + const token = (url.searchParams.get('token') ?? '').trim(); |
| 287 | + const nextRaw = url.searchParams.get('next') ?? '/'; |
| 288 | + const next = nextRaw.startsWith('/') ? nextRaw : '/'; |
| 289 | + if (!token) return c.text('missing token', 400); |
| 290 | + |
| 291 | + if (typeof authSvc?.getAuthContext !== 'function') { |
| 292 | + return c.text('auth service unavailable', 503); |
| 293 | + } |
| 294 | + const authCtx: any = await authSvc.getAuthContext(); |
| 295 | + const internal = authCtx?.internalAdapter; |
| 296 | + if (!internal?.consumeVerificationValue) { |
| 297 | + return c.text('verification API unavailable', 503); |
| 298 | + } |
| 299 | + |
| 300 | + const consumed = await internal.consumeVerificationValue(`sso-handoff:${token}`); |
| 301 | + if (!consumed) return c.text('invalid or expired token', 401); |
| 302 | + const expiresAt = consumed?.expiresAt ? new Date(consumed.expiresAt).getTime() : 0; |
| 303 | + if (!expiresAt || expiresAt < Date.now()) return c.text('expired token', 401); |
| 304 | + |
| 305 | + let payload: { email?: string; name?: string | null } = {}; |
| 306 | + try { payload = JSON.parse(String(consumed.value)); } catch { payload = { email: String(consumed.value) }; } |
| 307 | + const email = String(payload.email ?? '').toLowerCase().trim(); |
| 308 | + if (!email) return c.text('handoff missing email', 400); |
| 309 | + |
| 310 | + const found = await internal.findUserByEmail(email, { includeAccounts: true }); |
| 311 | + let userId: string | undefined = found?.user?.id; |
| 312 | + let hasCredentialAccount = (found?.accounts ?? []).some((a: any) => a.providerId === 'credential' && a.password); |
| 313 | + |
| 314 | + if (!userId) { |
| 315 | + const created = await internal.createUser({ |
| 316 | + email, |
| 317 | + name: payload.name ?? email, |
| 318 | + emailVerified: true, |
| 319 | + }); |
| 320 | + userId = created?.id; |
| 321 | + hasCredentialAccount = false; |
| 322 | + } |
| 323 | + if (!userId) return c.text('failed to provision user', 500); |
| 324 | + |
| 325 | + const session = await internal.createSession(userId, false); |
| 326 | + const rawToken: string | undefined = session?.token; |
| 327 | + const sessionExpiresAt = session?.expiresAt ? new Date(session.expiresAt) : new Date(Date.now() + 7 * 24 * 3600 * 1000); |
| 328 | + if (!rawToken) return c.text('failed to mint session', 500); |
| 329 | + |
| 330 | + const secret: string = authCtx?.secret ?? ''; |
| 331 | + if (!secret) return c.text('auth secret unavailable', 503); |
| 332 | + const cookieName: string = authCtx?.authCookies?.sessionToken?.name ?? 'better-auth.session_token'; |
| 333 | + const cookieAttrs = authCtx?.authCookies?.sessionToken?.attributes ?? {}; |
| 334 | + const encoded = signSessionCookieValue(rawToken, secret); |
| 335 | + const maxAgeSec = Math.max(60, Math.floor((sessionExpiresAt.getTime() - Date.now()) / 1000)); |
| 336 | + const setCookie = buildSetCookieHeader(cookieName, encoded, cookieAttrs, maxAgeSec); |
| 337 | + |
| 338 | + const finalNext = hasCredentialAccount |
| 339 | + ? next |
| 340 | + : `/_console/system/profile?recovery_needed=true&next=${encodeURIComponent(next)}`; |
| 341 | + const headers = new Headers(); |
| 342 | + headers.set('Set-Cookie', setCookie); |
| 343 | + headers.set('Location', finalNext); |
| 344 | + headers.set('Cache-Control', 'no-store'); |
| 345 | + return new Response(null, { status: 302, headers }); |
| 346 | + } catch (err: any) { |
| 347 | + ctx.logger?.error?.('[AuthProxyPlugin] sso-exchange failed', err instanceof Error ? err : new Error(String(err))); |
| 348 | + return c.text(`sso-exchange failed: ${err?.message ?? String(err)}`, 500); |
| 349 | + } |
| 350 | + } |
| 351 | + |
165 | 352 | const fn = await resolveAuthHandler(authSvc); |
166 | 353 | if (!fn) { |
167 | 354 | return c.json({ error: 'auth_service_unavailable', environmentId }, 503); |
|
0 commit comments