diff --git a/docs/catalog.json b/docs/catalog.json index d8343dc2..01c8feed 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -963,6 +963,10 @@ "name": "PermissionLevelSchema", "file": "packages/core/src/contracts/schemas/iam.ts" }, + { + "name": "PhoneLoginErrorReasonSchema", + "file": "packages/core/src/contracts/schemas/identity.ts" + }, { "name": "PhoneLoginRequestInputSchema", "file": "packages/core/src/contracts/schemas/identity.ts" diff --git a/packages/core/src/contracts/schemas/identity.ts b/packages/core/src/contracts/schemas/identity.ts index fceb8f7f..592c4a29 100644 --- a/packages/core/src/contracts/schemas/identity.ts +++ b/packages/core/src/contracts/schemas/identity.ts @@ -39,6 +39,9 @@ export const PhoneLoginVerifyInputSchema = z.object({ rememberMe: z.boolean().optional(), }); +export const PHONE_LOGIN_ERROR_REASONS = ['otp_cancelled', 'rg_blocked'] as const; +export const PhoneLoginErrorReasonSchema = z.enum(PHONE_LOGIN_ERROR_REASONS); + export const OrganizationSchema = z.object({ id: UuidSchema, name: z.string().min(1).max(255), @@ -151,3 +154,4 @@ export type E164Phone = z.infer; export type PhoneLoginRequestInput = z.infer; export type PhoneLoginRequestOutput = z.infer; export type PhoneLoginVerifyInput = z.infer; +export type PhoneLoginErrorReason = z.infer; diff --git a/packages/core/src/pam/identity/__tests__/phone-login.service.test.ts b/packages/core/src/pam/identity/__tests__/phone-login.service.test.ts index 99286863..3e4c905b 100644 --- a/packages/core/src/pam/identity/__tests__/phone-login.service.test.ts +++ b/packages/core/src/pam/identity/__tests__/phone-login.service.test.ts @@ -1,13 +1,34 @@ -import { createHash } from 'node:crypto'; +import { createHash, createHmac } from 'node:crypto'; import { describe, it, expect, vi } from 'vitest'; import { ORPCError } from '@orpc/server'; import { PhoneLoginService } from '../service/phone-login.service.js'; import { makeDrizzle, makeEvents, mock } from '../../../testing/mock.js'; -import type { DrizzleService, EventBus } from '@openora/core/server'; +import type { DrizzleService, EventBus, Auth } from '@openora/core/server'; import type { RateLimiterAdapter, SmsAdapter } from '@openora/core/contracts'; const PHONE = '+14155550100'; +const AUTH_SECRET = 'unit-test-secret-do-not-use-in-prod'; +const SESSION_COOKIE_NAME = 'better-auth.session_token'; + +const fakeAuth: Auth = mock({ + $context: Promise.resolve({ + secret: AUTH_SECRET, + authCookies: { + sessionToken: { + name: SESSION_COOKIE_NAME, + attributes: { + httpOnly: true, + secure: false, + sameSite: 'lax', + path: '/', + maxAge: 7 * 24 * 60 * 60, + }, + }, + }, + }), +}); + // A verified-phone user row as `verifyOtp`'s `select().from(user)` returns it. const userRow = { id: 'u1', @@ -49,10 +70,15 @@ function build({ events: mock(events), sms, limiter: allowLimiter(), + auth: fakeAuth, }); return { svc, events, sms }; } +function expectedSignature(token: string): string { + return createHmac('sha256', AUTH_SECRET).update(token).digest('base64'); +} + const otpRow = (over: Record = {}) => ({ id: 'otp1', userId: 'u1', @@ -141,8 +167,9 @@ describe('PhoneLoginService.verifyOtp', () => { // (1) otp lookup, (2) user lookup, (3) delete otp in tx, (4) session insert in tx. select: [[otpRow({ codeHash: hash(code) })], [userRow], [], []], }); + const resHeaders = new Headers(); - const out = await svc.verifyOtp({ phone: PHONE, code }); + const out = await svc.verifyOtp({ phone: PHONE, code }, resHeaders); expect(out.user.id).toBe('u1'); expect(out.session.token).toEqual(expect.any(String)); @@ -153,15 +180,42 @@ describe('PhoneLoginService.verifyOtp', () => { ); }); - it('rememberMe extends the session TTL to ~30 days', async () => { + it('signs a session cookie the same auth instance can verify', async () => { const code = '123456'; const { svc } = build({ select: [[otpRow({ codeHash: hash(code) })], [userRow], [], []], }); + const resHeaders = new Headers(); - const out = await svc.verifyOtp({ phone: PHONE, code, rememberMe: true }); + const out = await svc.verifyOtp({ phone: PHONE, code }, resHeaders); + + const setCookie = resHeaders.get('set-cookie'); + expect(setCookie).not.toBeNull(); + expect(setCookie).toContain(`${SESSION_COOKIE_NAME}=`); + expect(setCookie).toContain('HttpOnly'); + expect(setCookie).not.toContain('Max-Age'); + + const cookieValue = decodeURIComponent( + setCookie?.split(';')[0]?.split('=').slice(1).join('=') ?? '', + ); + const sigPos = cookieValue.lastIndexOf('.'); + expect(cookieValue.slice(0, sigPos)).toBe(out.session.token); + expect(cookieValue.slice(sigPos + 1)).toBe(expectedSignature(out.session.token)); + }); + + it('rememberMe extends the session TTL to ~30 days and the cookie Max-Age to match', async () => { + const code = '123456'; + const { svc } = build({ + select: [[otpRow({ codeHash: hash(code) })], [userRow], [], []], + }); + const resHeaders = new Headers(); + + const out = await svc.verifyOtp({ phone: PHONE, code, rememberMe: true }, resHeaders); const ttlMs = new Date(out.session.expiresAt).getTime() - Date.now(); expect(ttlMs).toBeGreaterThan(29 * 24 * 60 * 60 * 1000); + + const maxAgeMatch = resHeaders.get('set-cookie')?.match(/Max-Age=(\d+)/); + expect(Number(maxAgeMatch?.[1])).toBeGreaterThan(29 * 24 * 60 * 60); }); it('wrong code increments failedAttempts and throws OtpInvalidError with attemptsRemaining', async () => { @@ -172,7 +226,7 @@ describe('PhoneLoginService.verifyOtp', () => { returning: [[{ failedAttempts: 2 }]], }); - const promise = svc.verifyOtp({ phone: PHONE, code: '111111' }); + const promise = svc.verifyOtp({ phone: PHONE, code: '111111' }, new Headers()); await expect(promise).rejects.toBeInstanceOf(ORPCError); await expect(promise).rejects.toMatchObject({ code: 'UNPROCESSABLE_CONTENT', @@ -188,7 +242,9 @@ describe('PhoneLoginService.verifyOtp', () => { returning: [[{ failedAttempts: 5 }]], }); - await expect(svc.verifyOtp({ phone: PHONE, code: '111111' })).rejects.toMatchObject({ + await expect( + svc.verifyOtp({ phone: PHONE, code: '111111' }, new Headers()), + ).rejects.toMatchObject({ code: 'FORBIDDEN', }); expect(events.emit).toHaveBeenCalledWith('identity.phone_otp.cancelled', { @@ -212,7 +268,7 @@ describe('PhoneLoginService.verifyOtp', () => { ], }); - await expect(svc.verifyOtp({ phone: PHONE, code })).rejects.toMatchObject({ + await expect(svc.verifyOtp({ phone: PHONE, code }, new Headers())).rejects.toMatchObject({ code: 'UNPROCESSABLE_CONTENT', data: { attemptsRemaining: 3 }, }); @@ -220,7 +276,9 @@ describe('PhoneLoginService.verifyOtp', () => { it('missing OTP session throws OtpInvalidError with 0 attemptsRemaining', async () => { const { svc } = build({ select: [[]] }); - await expect(svc.verifyOtp({ phone: PHONE, code: '123456' })).rejects.toMatchObject({ + await expect( + svc.verifyOtp({ phone: PHONE, code: '123456' }, new Headers()), + ).rejects.toMatchObject({ code: 'UNPROCESSABLE_CONTENT', data: { attemptsRemaining: 0 }, }); @@ -235,10 +293,13 @@ describe('PhoneLoginService.verifyOtp', () => { [{ ...userRow, rgBlocked: true, rgBlockedUntil: null }], ], }); + const resHeaders = new Headers(); - await expect(svc.verifyOtp({ phone: PHONE, code })).rejects.toMatchObject({ + await expect(svc.verifyOtp({ phone: PHONE, code }, resHeaders)).rejects.toMatchObject({ code: 'FORBIDDEN', + data: { reason: 'rg_blocked' }, }); + expect(resHeaders.get('set-cookie')).toBeNull(); expect(events.emit).toHaveBeenCalledWith( 'rg.exclusion.login_blocked', expect.objectContaining({ userId: 'u1' }), diff --git a/packages/core/src/pam/identity/plugin.ts b/packages/core/src/pam/identity/plugin.ts index d2c1a833..2f54858b 100644 --- a/packages/core/src/pam/identity/plugin.ts +++ b/packages/core/src/pam/identity/plugin.ts @@ -12,7 +12,7 @@ import { SESSION_COMMANDS, SMS_ADAPTER, } from '@openora/core/contracts'; -import { definePlugin, ADMIN_GUARD, EVENT_BUS, DRIZZLE } from '@openora/core/server'; +import { definePlugin, ADMIN_GUARD, EVENT_BUS, DRIZZLE, AUTH_SESSION } from '@openora/core/server'; import { MockKycAdapter } from './adapters/mock/mock-kyc-adapter.js'; import { MockSmsAdapter } from './adapters/mock/mock-sms-adapter.js'; import { PhoneLoginService } from './service/phone-login.service.js'; @@ -76,6 +76,7 @@ export default definePlugin({ events: c.get(EVENT_BUS), sms: c.get(SMS_ADAPTER), limiter: c.get(RATE_LIMITER), + auth: c.get(AUTH_SESSION).auth, }), c.get(ADMIN_GUARD), c.get(EVENT_BUS), diff --git a/packages/core/src/pam/identity/router/index.ts b/packages/core/src/pam/identity/router/index.ts index 42eaa0b4..202d6780 100644 --- a/packages/core/src/pam/identity/router/index.ts +++ b/packages/core/src/pam/identity/router/index.ts @@ -55,11 +55,14 @@ export function createIdentityRouter( phoneLoginVerify: os.phoneLoginVerify.handler(({ input, context }) => { const h = context.request.headers; - return phoneLogin.verifyOtp({ - ...input, - ip: nodeForwardedFor(h), - userAgent: nodeUserAgent(h), - }); + return phoneLogin.verifyOtp( + { + ...input, + ip: nodeForwardedFor(h), + userAgent: nodeUserAgent(h), + }, + context.resHeaders ?? new Headers(), + ); }), logout: os.logout.handler(({ context }) => diff --git a/packages/core/src/pam/identity/service/phone-login.service.ts b/packages/core/src/pam/identity/service/phone-login.service.ts index ad9298e1..6e5036d8 100644 --- a/packages/core/src/pam/identity/service/phone-login.service.ts +++ b/packages/core/src/pam/identity/service/phone-login.service.ts @@ -1,7 +1,14 @@ import { createHash, randomInt, randomUUID } from 'node:crypto'; import { ORPCError } from '@orpc/server'; -import { type EventBus, DrizzleService, assertRateLimit } from '@openora/core/server'; +import { + type EventBus, + type Auth, + DrizzleService, + assertRateLimit, + signSessionCookie, +} from '@openora/core/server'; import { and, eq, gt, sql } from 'drizzle-orm'; +import { PhoneLoginErrorReasonSchema } from '@openora/core/contracts'; import type { RateLimiterAdapter, SmsAdapter, @@ -51,6 +58,7 @@ export function OtpInvalidError(attemptsRemaining: number) { export function OtpCancelledError() { return new ORPCError('FORBIDDEN', { message: 'Too many incorrect attempts. Please request a new code.', + data: { reason: PhoneLoginErrorReasonSchema.enum.otp_cancelled }, }); } @@ -102,6 +110,7 @@ export type PhoneLoginServiceDeps = { events: EventBus; sms: SmsAdapter; limiter: RateLimiterAdapter; + auth: Auth; }; export class PhoneLoginService { @@ -109,12 +118,14 @@ export class PhoneLoginService { private readonly events: EventBus; private readonly sms: SmsAdapter; private readonly limiter: RateLimiterAdapter; + private readonly auth: Auth; - constructor({ drizzle, events, sms, limiter }: PhoneLoginServiceDeps) { + constructor({ drizzle, events, sms, limiter, auth }: PhoneLoginServiceDeps) { this.drizzle = drizzle; this.events = events; this.sms = sms; this.limiter = limiter; + this.auth = auth; } async requestOtp( @@ -178,6 +189,7 @@ export class PhoneLoginService { async verifyOtp( input: PhoneLoginVerifyInput & { ip?: string | null; userAgent?: string | null }, + resHeaders: Headers, ) { const { phone, code, rememberMe, ip = null, userAgent = null } = input; await assertRateLimit(this.limiter, `phone-otp-verify:${phone}`, OTP_VERIFY_RATE_LIMIT); @@ -261,6 +273,7 @@ export class PhoneLoginService { this.events.emit('rg.exclusion.login_blocked', { userId: account.id, ip, userAgent }); throw new ORPCError('FORBIDDEN', { message: 'Account access is currently restricted (responsible gambling).', + data: { reason: PhoneLoginErrorReasonSchema.enum.rg_blocked }, }); } @@ -293,6 +306,21 @@ export class PhoneLoginService { userAgent, }); + const authContext = await this.auth.$context; + + const maxAgeSeconds = rememberMe + ? Math.max(0, Math.round((sessionExpiresAt.getTime() - Date.now()) / 1000)) + : undefined; + resHeaders.append( + 'set-cookie', + signSessionCookie({ + token, + sessionCookie: authContext.authCookies.sessionToken, + secret: authContext.secret, + maxAgeSeconds, + }), + ); + return { user: serializeUser(account), session: { token, expiresAt: sessionExpiresAt.toISOString() }, diff --git a/packages/core/src/server/auth/__tests__/sign-session-cookie.test.ts b/packages/core/src/server/auth/__tests__/sign-session-cookie.test.ts new file mode 100644 index 00000000..73942773 --- /dev/null +++ b/packages/core/src/server/auth/__tests__/sign-session-cookie.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { signSessionCookie } from '../sign-session-cookie.js'; + +const SESSION_COOKIE = { + name: 'better-auth.session_token', + attributes: { httpOnly: true, secure: false, sameSite: 'lax' as const, path: '/' }, +}; + +describe('signSessionCookie', () => { + it('produces a signature matching a known-good vector', () => { + const cookie = signSessionCookie({ + token: 'fixed-token-for-golden-vector', + sessionCookie: SESSION_COOKIE, + secret: 'unit-test-secret-do-not-use-in-prod', + maxAgeSeconds: 3600, + }); + + expect(cookie).toContain( + 'better-auth.session_token=fixed-token-for-golden-vector.HUa0peblWWtfebbtuTmir7Km5kqZbRR2ZVIMnEP8BDQ%3D', + ); + }); + + it('carries the cookie attributes and the given max-age', () => { + const cookie = signSessionCookie({ + token: 't', + sessionCookie: SESSION_COOKIE, + secret: 's', + maxAgeSeconds: 86_400, + }); + + expect(cookie).toContain('Max-Age=86400'); + expect(cookie).toContain('Path=/'); + expect(cookie).toContain('HttpOnly'); + expect(cookie).toContain('SameSite=Lax'); + expect(cookie).not.toContain('Secure'); + }); + + it('differs for different secrets, so a signature cannot be replayed across environments', () => { + const a = signSessionCookie({ + token: 'same-token', + sessionCookie: SESSION_COOKIE, + secret: 'secret-a', + maxAgeSeconds: 60, + }); + const b = signSessionCookie({ + token: 'same-token', + sessionCookie: SESSION_COOKIE, + secret: 'secret-b', + maxAgeSeconds: 60, + }); + expect(a).not.toBe(b); + }); + + it('omits Max-Age when no ttl is given, producing a real browser session cookie', () => { + const cookieWithInheritedDefault = { + name: 'better-auth.session_token', + attributes: { ...SESSION_COOKIE.attributes, maxAge: 7 * 24 * 60 * 60 }, + }; + + const cookie = signSessionCookie({ + token: 't', + sessionCookie: cookieWithInheritedDefault, + secret: 's', + maxAgeSeconds: undefined, + }); + + expect(cookie).not.toContain('Max-Age'); + }); +}); diff --git a/packages/core/src/server/auth/index.ts b/packages/core/src/server/auth/index.ts index b27bf294..77b61d05 100644 --- a/packages/core/src/server/auth/index.ts +++ b/packages/core/src/server/auth/index.ts @@ -2,6 +2,7 @@ export type { AuthOptions, Auth, SendEmail } from './auth.js'; export { createAuth } from './auth.js'; export { AdminGuard, ADMIN_GUARD } from './admin-guard.js'; export { SessionResolver, AUTH_SESSION } from './session-resolver.js'; +export { signSessionCookie, type SessionCookieConfig } from './sign-session-cookie.js'; export { ac, roles, statement } from './permissions.js'; export type { RoleName, ResourceName, ActionOf } from './permissions.js'; export { diff --git a/packages/core/src/server/auth/sign-session-cookie.ts b/packages/core/src/server/auth/sign-session-cookie.ts new file mode 100644 index 00000000..0264f9ec --- /dev/null +++ b/packages/core/src/server/auth/sign-session-cookie.ts @@ -0,0 +1,36 @@ +import { createHmac } from 'node:crypto'; +import { serialize } from 'hono/utils/cookie'; + +export type SessionCookieConfig = { + name: string; + attributes: { + domain?: string; + httpOnly?: boolean; + maxAge?: number; + path?: string; + secure?: boolean; + sameSite?: 'strict' | 'lax' | 'none' | 'Strict' | 'Lax' | 'None'; + }; +}; + +export function signSessionCookie({ + token, + sessionCookie, + secret, + maxAgeSeconds, +}: { + token: string; + sessionCookie: SessionCookieConfig; + secret: string; + maxAgeSeconds?: number; +}): string { + const signature = createHmac('sha256', secret).update(token).digest('base64'); + const signedValue = `${token}.${signature}`; + const attributes = { ...sessionCookie.attributes }; + if (maxAgeSeconds === undefined) { + delete attributes.maxAge; + } else { + attributes.maxAge = maxAgeSeconds; + } + return serialize(sessionCookie.name, signedValue, attributes); +}