Revert "style: lint"

This reverts commit 98f40d7d0b.
This commit is contained in:
Neko Ayaka
2026-08-26 20:13:10 +08:00
parent cfcfc513ef
commit 146b3da65a
1625 changed files with 75440 additions and 75453 deletions
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -12,7 +12,7 @@ const logger = useLogger('db')
/** Database projection visible to the Auth runtime. */
export type AuthDatabase = ReturnType<typeof createAuthDrizzle>['db']
type AuthDrizzleEnv = Pick<AuthEnv, 'DATABASE_URL' | 'DB_POOL_CONNECTION_TIMEOUT_MS' | 'DB_POOL_IDLE_TIMEOUT_MS' | 'DB_POOL_KEEPALIVE_INITIAL_DELAY_MS' | 'DB_POOL_MAX'>
type AuthDrizzleEnv = Pick<AuthEnv, 'DATABASE_URL' | 'DB_POOL_MAX' | 'DB_POOL_IDLE_TIMEOUT_MS' | 'DB_POOL_CONNECTION_TIMEOUT_MS' | 'DB_POOL_KEEPALIVE_INITIAL_DELAY_MS'>
/**
* Creates the auth service's database projection. Business tables are not
@@ -23,11 +23,11 @@ export function createAuthDrizzle(env: AuthDrizzleEnv) {
// before the Auth application modules are evaluated.
const pool = new pg.Pool({
connectionString: env.DATABASE_URL,
connectionTimeoutMillis: env.DB_POOL_CONNECTION_TIMEOUT_MS,
max: env.DB_POOL_MAX,
idleTimeoutMillis: env.DB_POOL_IDLE_TIMEOUT_MS,
connectionTimeoutMillis: env.DB_POOL_CONNECTION_TIMEOUT_MS,
keepAlive: true,
keepAliveInitialDelayMillis: env.DB_POOL_KEEPALIVE_INITIAL_DELAY_MS,
max: env.DB_POOL_MAX,
})
pool.on('error', (error) => {
+138 -138
View File
@@ -20,14 +20,14 @@ import { ApiError } from './error'
* - `to` is already validated by Better Auth (we trust caller for internal flows).
*/
export interface EmailPayload {
/** HTML body. */
html: string
/** Subject line. Plain text. */
subject: string
/** Plain-text body. Required for spam-filter parity and accessibility. */
text: string
/** Recipient address. Single address — Better Auth callbacks always emit one. */
to: string
/** Subject line. Plain text. */
subject: string
/** HTML body. */
html: string
/** Plain-text body. Required for spam-filter parity and accessibility. */
text: string
}
/**
@@ -45,7 +45,10 @@ export interface EmailPayload {
*/
export interface EmailService {
send: (payload: EmailPayload) => Promise<void>
sendChangeEmailConfirmation: (params: { newEmail: string, to: string, url: string }) => Promise<void>
sendVerification: (params: { to: string, url: string }) => Promise<void>
sendPasswordReset: (params: { to: string, url: string }) => Promise<void>
sendMagicLink: (params: { to: string, url: string }) => Promise<void>
sendChangeEmailConfirmation: (params: { to: string, newEmail: string, url: string }) => Promise<void>
/**
* Send the irreversible-action confirmation for `user.deleteUser` flow.
*
@@ -57,9 +60,6 @@ export interface EmailService {
* Source: node_modules/better-auth/dist/api/routes/update-user.mjs L286-300.
*/
sendDeleteAccountVerification: (params: { to: string, url: string }) => Promise<void>
sendMagicLink: (params: { to: string, url: string }) => Promise<void>
sendPasswordReset: (params: { to: string, url: string }) => Promise<void>
sendVerification: (params: { to: string, url: string }) => Promise<void>
}
interface EmailConfig {
@@ -68,6 +68,21 @@ interface EmailConfig {
fromName?: string
}
/**
* Format an RFC 5322 display-name + address pair for the `From` header.
*
* Before:
* - `{ fromEmail: 'noreply@a.io', fromName: 'AIRI' }`
*
* After:
* - `'AIRI <noreply@a.io>'`
*/
function formatFrom(config: EmailConfig): string {
if (config.fromName)
return `${config.fromName} <${config.fromEmail}>`
return config.fromEmail
}
/**
* Construct the email service.
*
@@ -88,7 +103,7 @@ export function createEmailService(config: EmailConfig, logger: Logger = useLogg
// keys; explicit guard keeps the failure mode visible at the call site.
// Source: node_modules/.pnpm/resend@*/node_modules/resend/dist/index.cjs
// Removal condition: when we make RESEND_API_KEY required at env-parse time.
let client: null | Resend = null
let client: Resend | null = null
function getClient(): Resend {
if (!client) {
if (!config.apiKey) {
@@ -110,78 +125,83 @@ export function createEmailService(config: EmailConfig, logger: Logger = useLogg
try {
const { error } = await getClient().emails.send({
from,
html: payload.html,
subject: payload.subject,
text: payload.text,
to: [payload.to],
subject: payload.subject,
html: payload.html,
text: payload.text,
})
if (error) {
logger.withFields({ errorName: error.name, subject: payload.subject, to: payload.to }).error(error.message)
metrics?.failures.add(1, { error_name: error.name, template })
metrics?.duration.record((Date.now() - startedAt) / 1000, { outcome: 'error', template })
logger.withFields({ to: payload.to, subject: payload.subject, errorName: error.name }).error(error.message)
metrics?.failures.add(1, { template, error_name: error.name })
metrics?.duration.record((Date.now() - startedAt) / 1000, { template, outcome: 'error' })
throw new ApiError(502, 'email/send_failed', error.message, { providerError: error.name })
}
metrics?.send.add(1, { template })
metrics?.duration.record((Date.now() - startedAt) / 1000, { outcome: 'ok', template })
metrics?.duration.record((Date.now() - startedAt) / 1000, { template, outcome: 'ok' })
}
catch (error) {
if (error instanceof ApiError)
throw error
const message = errorMessageFrom(error) ?? 'Unknown email send error'
logger.withFields({ subject: payload.subject, to: payload.to }).error(message)
metrics?.failures.add(1, { error_name: 'unhandled', template })
metrics?.duration.record((Date.now() - startedAt) / 1000, { outcome: 'error', template })
logger.withFields({ to: payload.to, subject: payload.subject }).error(message)
metrics?.failures.add(1, { template, error_name: 'unhandled' })
metrics?.duration.record((Date.now() - startedAt) / 1000, { template, outcome: 'error' })
throw new ApiError(502, 'email/send_failed', message)
}
}
return {
send,
async sendChangeEmailConfirmation({ newEmail, to, url }) {
async sendVerification({ to, url }) {
await send({
html: renderChangeEmailHtml(url, newEmail),
subject: 'Confirm your new email address for Project AIRI',
text: renderChangeEmailText(url, newEmail),
to,
subject: 'Verify your email for Project AIRI',
html: renderVerificationHtml(url),
text: renderVerificationText(url),
}, 'verification')
},
async sendPasswordReset({ to, url }) {
await send({
to,
subject: 'Reset your Project AIRI password',
html: renderPasswordResetHtml(url),
text: renderPasswordResetText(url),
}, 'password_reset')
},
async sendMagicLink({ to, url }) {
await send({
to,
subject: 'Your Project AIRI sign-in link',
html: renderMagicLinkHtml(url),
text: renderMagicLinkText(url),
}, 'magic_link')
},
async sendChangeEmailConfirmation({ to, newEmail, url }) {
await send({
to,
subject: 'Confirm your new email address for Project AIRI',
html: renderChangeEmailHtml(url, newEmail),
text: renderChangeEmailText(url, newEmail),
}, 'change_email')
},
async sendDeleteAccountVerification({ to, url }) {
await send({
html: renderDeleteAccountHtml(url),
to,
subject: 'Confirm account deletion for Project AIRI',
html: renderDeleteAccountHtml(url),
text: renderDeleteAccountText(url),
to,
}, 'delete_account')
},
async sendMagicLink({ to, url }) {
await send({
html: renderMagicLinkHtml(url),
subject: 'Your Project AIRI sign-in link',
text: renderMagicLinkText(url),
to,
}, 'magic_link')
},
async sendPasswordReset({ to, url }) {
await send({
html: renderPasswordResetHtml(url),
subject: 'Reset your Project AIRI password',
text: renderPasswordResetText(url),
to,
}, 'password_reset')
},
async sendVerification({ to, url }) {
await send({
html: renderVerificationHtml(url),
subject: 'Verify your email for Project AIRI',
text: renderVerificationText(url),
to,
}, 'verification')
},
}
}
// NOTICE:
// Templates are intentionally minimal inline HTML. Goal here is functional
// delivery + plaintext fallback. Visual design is deferred (see
// docs/ai/context/email-auth-resend.md "不做" section).
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
@@ -191,27 +211,7 @@ function escapeHtml(value: string): string {
.replace(/'/g, '&#39;')
}
// NOTICE:
// Templates are intentionally minimal inline HTML. Goal here is functional
// delivery + plaintext fallback. Visual design is deferred (see
// docs/ai/context/email-auth-resend.md "不做" section).
/**
* Format an RFC 5322 display-name + address pair for the `From` header.
*
* Before:
* - `{ fromEmail: 'noreply@a.io', fromName: 'AIRI' }`
*
* After:
* - `'AIRI <noreply@a.io>'`
*/
function formatFrom(config: EmailConfig): string {
if (config.fromName)
return `${config.fromName} <${config.fromEmail}>`
return config.fromEmail
}
function renderActionEmailHtml(args: { body: string, ctaLabel: string, footer: string, heading: string, url: string }): string {
function renderActionEmailHtml(args: { heading: string, body: string, ctaLabel: string, url: string, footer: string }): string {
const safeUrl = escapeHtml(args.url)
return `<!doctype html>
<html><body style="font-family: -apple-system, Segoe UI, sans-serif; color: #111; max-width: 480px; margin: 24px auto; padding: 0 16px;">
@@ -223,26 +223,83 @@ function renderActionEmailHtml(args: { body: string, ctaLabel: string, footer: s
</body></html>`
}
function renderActionEmailText(args: { body: string, footer: string, heading: string, url: string }): string {
function renderActionEmailText(args: { heading: string, body: string, url: string, footer: string }): string {
return `${args.heading}\n\n${args.body}\n\n${args.url}\n\n${args.footer}\n`
}
function renderVerificationHtml(url: string): string {
return renderActionEmailHtml({
heading: 'Verify your email',
body: 'Welcome to Project AIRI. Click the button below to confirm this is your email address.',
ctaLabel: 'Verify email',
url,
footer: 'If you did not create an account, you can safely ignore this email.',
})
}
function renderVerificationText(url: string): string {
return renderActionEmailText({
heading: 'Verify your email',
body: 'Welcome to Project AIRI. Open this link to confirm your email address:',
url,
footer: 'If you did not create an account, you can safely ignore this email.',
})
}
function renderPasswordResetHtml(url: string): string {
return renderActionEmailHtml({
heading: 'Reset your password',
body: 'We received a request to reset the password for your Project AIRI account.',
ctaLabel: 'Reset password',
url,
footer: 'If you did not request this, you can safely ignore this email — your password will not change.',
})
}
function renderPasswordResetText(url: string): string {
return renderActionEmailText({
heading: 'Reset your password',
body: 'Open this link to reset your Project AIRI password:',
url,
footer: 'If you did not request this, you can safely ignore this email — your password will not change.',
})
}
function renderMagicLinkHtml(url: string): string {
return renderActionEmailHtml({
heading: 'Sign in to Project AIRI',
body: 'Click the button below to sign in. This link expires shortly and can be used once.',
ctaLabel: 'Sign in',
url,
footer: 'If you did not request this link, you can safely ignore this email.',
})
}
function renderMagicLinkText(url: string): string {
return renderActionEmailText({
heading: 'Sign in to Project AIRI',
body: 'Open this link to sign in (single-use, expires shortly):',
url,
footer: 'If you did not request this link, you can safely ignore this email.',
})
}
function renderChangeEmailHtml(url: string, newEmail: string): string {
return renderActionEmailHtml({
heading: 'Confirm your new email',
body: `Confirm that ${newEmail} should become your Project AIRI account email.`,
ctaLabel: 'Confirm new email',
footer: 'If you did not request this change, contact support immediately.',
heading: 'Confirm your new email',
url,
footer: 'If you did not request this change, contact support immediately.',
})
}
function renderChangeEmailText(url: string, newEmail: string): string {
return renderActionEmailText({
body: `Confirm that ${newEmail} should become your Project AIRI account email by opening this link:`,
footer: 'If you did not request this change, contact support immediately.',
heading: 'Confirm your new email',
body: `Confirm that ${newEmail} should become your Project AIRI account email by opening this link:`,
url,
footer: 'If you did not request this change, contact support immediately.',
})
}
@@ -253,76 +310,19 @@ function renderChangeEmailText(url: string, newEmail: string): string {
// See `server/apps/api/docs/ai-context/account-deletion.md`.
function renderDeleteAccountHtml(url: string): string {
return renderActionEmailHtml({
heading: 'Confirm account deletion',
body: 'Click below to permanently delete your Project AIRI account. This cannot be undone. Active subscription will be canceled, Flux balance cleared. Link expires in 24 hours.',
ctaLabel: 'Delete my account',
footer: 'Did not request this? Ignore this email and rotate your password.',
heading: 'Confirm account deletion',
url,
footer: 'Did not request this? Ignore this email and rotate your password.',
})
}
function renderDeleteAccountText(url: string): string {
return renderActionEmailText({
body: 'Open this link to permanently delete your Project AIRI account. This cannot be undone. Active subscription will be canceled, Flux balance cleared. Link expires in 24 hours.',
footer: 'Did not request this? Ignore this email and rotate your password.',
heading: 'Confirm account deletion',
body: 'Open this link to permanently delete your Project AIRI account. This cannot be undone. Active subscription will be canceled, Flux balance cleared. Link expires in 24 hours.',
url,
})
}
function renderMagicLinkHtml(url: string): string {
return renderActionEmailHtml({
body: 'Click the button below to sign in. This link expires shortly and can be used once.',
ctaLabel: 'Sign in',
footer: 'If you did not request this link, you can safely ignore this email.',
heading: 'Sign in to Project AIRI',
url,
})
}
function renderMagicLinkText(url: string): string {
return renderActionEmailText({
body: 'Open this link to sign in (single-use, expires shortly):',
footer: 'If you did not request this link, you can safely ignore this email.',
heading: 'Sign in to Project AIRI',
url,
})
}
function renderPasswordResetHtml(url: string): string {
return renderActionEmailHtml({
body: 'We received a request to reset the password for your Project AIRI account.',
ctaLabel: 'Reset password',
footer: 'If you did not request this, you can safely ignore this email — your password will not change.',
heading: 'Reset your password',
url,
})
}
function renderPasswordResetText(url: string): string {
return renderActionEmailText({
body: 'Open this link to reset your Project AIRI password:',
footer: 'If you did not request this, you can safely ignore this email — your password will not change.',
heading: 'Reset your password',
url,
})
}
function renderVerificationHtml(url: string): string {
return renderActionEmailHtml({
body: 'Welcome to Project AIRI. Click the button below to confirm this is your email address.',
ctaLabel: 'Verify email',
footer: 'If you did not create an account, you can safely ignore this email.',
heading: 'Verify your email',
url,
})
}
function renderVerificationText(url: string): string {
return renderActionEmailText({
body: 'Welcome to Project AIRI. Open this link to confirm your email address:',
footer: 'If you did not create an account, you can safely ignore this email.',
heading: 'Verify your email',
url,
footer: 'Did not request this? Ignore this email and rotate your password.',
})
}
+22 -22
View File
@@ -30,7 +30,21 @@ const AdditionalTrustedOriginsSchema = pipe(
)
const AuthEnvSchema = object({
HOST: optional(string(), '0.0.0.0'),
PORT: optionalIntegerFromString(3000, 'PORT', 1),
PUBLIC_URL: optional(string(), 'http://localhost:3000'),
RESOURCE_SERVER_URL: optional(string(), 'http://localhost:3001'),
RATE_LIMIT_TRUSTED_PROXY: optional(picklist(['railway'])),
AUTH_UI_URL: optional(string(), 'https://accounts.airi.build/ui'),
ADDITIONAL_TRUSTED_ORIGINS: optional(AdditionalTrustedOriginsSchema, ''),
DATABASE_URL: pipe(string(), nonEmpty('DATABASE_URL is required')),
REDIS_URL: pipe(string(), nonEmpty('REDIS_URL is required')),
BETTER_AUTH_SECRET: pipe(string(), nonEmpty('BETTER_AUTH_SECRET is required')),
AUTH_GOOGLE_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_ID is required')),
AUTH_GOOGLE_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_SECRET is required')),
AUTH_GITHUB_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')),
AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET is required')),
AUTH_APPLE_CLIENT_ID: optional(string(), ''),
AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: optional(
pipe(
string(),
@@ -43,7 +57,7 @@ const AuthEnvSchema = object({
),
'',
),
AUTH_APPLE_CLIENT_ID: optional(string(), ''),
AUTH_APPLE_TEAM_ID: optional(string(), ''),
AUTH_APPLE_KEY_ID: optional(string(), ''),
AUTH_APPLE_PRIVATE_KEY_PEM: optional(
pipe(
@@ -54,36 +68,22 @@ const AuthEnvSchema = object({
),
'',
),
AUTH_APPLE_TEAM_ID: optional(string(), ''),
AUTH_GITHUB_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')),
AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET is required')),
AUTH_GOOGLE_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_ID is required')),
AUTH_GOOGLE_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_SECRET is required')),
AUTH_UI_URL: optional(string(), 'https://accounts.airi.build/ui'),
BETTER_AUTH_SECRET: pipe(string(), nonEmpty('BETTER_AUTH_SECRET is required')),
DATABASE_URL: pipe(string(), nonEmpty('DATABASE_URL is required')),
DB_POOL_CONNECTION_TIMEOUT_MS: optionalIntegerFromString(5000, 'DB_POOL_CONNECTION_TIMEOUT_MS', 1),
DB_POOL_IDLE_TIMEOUT_MS: optionalIntegerFromString(30000, 'DB_POOL_IDLE_TIMEOUT_MS', 1),
DB_POOL_KEEPALIVE_INITIAL_DELAY_MS: optionalIntegerFromString(10000, 'DB_POOL_KEEPALIVE_INITIAL_DELAY_MS', 1),
DB_POOL_MAX: optionalIntegerFromString(20, 'DB_POOL_MAX', 1),
HOST: optional(string(), '0.0.0.0'),
OTEL_EXPORTER_OTLP_ENDPOINT: optional(string()),
OTEL_SERVICE_NAME: optional(string(), 'auth-server'),
PORT: optionalIntegerFromString(3000, 'PORT', 1),
PUBLIC_URL: optional(string(), 'http://localhost:3000'),
RATE_LIMIT_TRUSTED_PROXY: optional(picklist(['railway'])),
REDIS_URL: pipe(string(), nonEmpty('REDIS_URL is required')),
RESEND_API_KEY: optional(string(), ''),
RESEND_FROM_EMAIL: optional(string(), 'noreply@airi.moeru.ai'),
RESEND_FROM_NAME: optional(string(), 'Project AIRI'),
RESOURCE_SERVER_URL: optional(string(), 'http://localhost:3001'),
DB_POOL_MAX: optionalIntegerFromString(20, 'DB_POOL_MAX', 1),
DB_POOL_IDLE_TIMEOUT_MS: optionalIntegerFromString(30000, 'DB_POOL_IDLE_TIMEOUT_MS', 1),
DB_POOL_CONNECTION_TIMEOUT_MS: optionalIntegerFromString(5000, 'DB_POOL_CONNECTION_TIMEOUT_MS', 1),
DB_POOL_KEEPALIVE_INITIAL_DELAY_MS: optionalIntegerFromString(10000, 'DB_POOL_KEEPALIVE_INITIAL_DELAY_MS', 1),
OTEL_SERVICE_NAME: optional(string(), 'auth-server'),
OTEL_EXPORTER_OTLP_ENDPOINT: optional(string()),
})
/** Environment owned exclusively by the standalone Auth process. */
export type AuthEnv = InferOutput<typeof AuthEnvSchema>
/** Parses only Auth-owned configuration; business-only secrets are ignored. */
export function parseAuthEnv(inputEnv: NodeJS.ProcessEnv | Record<string, string>): AuthEnv {
export function parseAuthEnv(inputEnv: Record<string, string> | NodeJS.ProcessEnv): AuthEnv {
try {
return parse(AuthEnvSchema, inputEnv)
}
+28 -28
View File
@@ -12,6 +12,34 @@ export class ApiError extends Error {
}
}
/**
* Creates an internal server error (500)
*/
export function createInternalError(message = 'Internal Server Error', details?: unknown) {
return new ApiError(500, 'INTERNAL_SERVER_ERROR', message, details)
}
/**
* Creates a bad request error (400)
*/
export function createBadRequestError(message: string, errorCode = 'BAD_REQUEST', details?: unknown) {
return new ApiError(400, errorCode, message, details)
}
/**
* Creates a forbidden error (403)
*/
export function createForbiddenError(message = 'Forbidden', details?: unknown) {
return new ApiError(403, 'FORBIDDEN', message, details)
}
/**
* Creates a service unavailable error (503)
*/
export function createServiceUnavailableError(message = 'Service Unavailable', errorCode = 'SERVICE_UNAVAILABLE', details?: unknown) {
return new ApiError(503, errorCode, message, details)
}
/**
* Creates a bad gateway error (502).
*
@@ -30,31 +58,3 @@ export class ApiError extends Error {
export function createBadGatewayError(message = 'Bad Gateway', details?: unknown) {
return new ApiError(502, 'BAD_GATEWAY', message, details)
}
/**
* Creates a bad request error (400)
*/
export function createBadRequestError(message: string, errorCode = 'BAD_REQUEST', details?: unknown) {
return new ApiError(400, errorCode, message, details)
}
/**
* Creates a forbidden error (403)
*/
export function createForbiddenError(message = 'Forbidden', details?: unknown) {
return new ApiError(403, 'FORBIDDEN', message, details)
}
/**
* Creates an internal server error (500)
*/
export function createInternalError(message = 'Internal Server Error', details?: unknown) {
return new ApiError(500, 'INTERNAL_SERVER_ERROR', message, details)
}
/**
* Creates a service unavailable error (503)
*/
export function createServiceUnavailableError(message = 'Service Unavailable', errorCode = 'SERVICE_UNAVAILABLE', details?: unknown) {
return new ApiError(503, errorCode, message, details)
}
+1 -1
View File
@@ -124,7 +124,7 @@ const ALWAYS_TRUSTED_AUTH_ORIGINS = [
* - De-duplicated origins in insertion order (Auth URL, env extras, localhost wildcards, then request-derived).
*/
export function getAuthTrustedOrigins(
env: { ADDITIONAL_TRUSTED_ORIGINS: readonly string[], PUBLIC_URL: string },
env: { PUBLIC_URL: string, ADDITIONAL_TRUSTED_ORIGINS: readonly string[] },
request?: Request,
): string[] {
const origins = new Set<string>()
+23 -23
View File
@@ -9,8 +9,18 @@ const logger = useLogger('otel')
export interface AuthMetrics {
attempts: Counter
failures: Counter
userLogin: Counter
userRegistered: Counter
userLogin: Counter
}
export interface EmailMetrics {
send: Counter
failures: Counter
duration: Histogram
}
export interface RateLimitMetrics {
blocked: Counter
}
export interface AuthOtelInstance {
@@ -19,16 +29,6 @@ export interface AuthOtelInstance {
rateLimit: RateLimitMetrics
}
export interface EmailMetrics {
duration: Histogram
failures: Counter
send: Counter
}
export interface RateLimitMetrics {
blocked: Counter
}
/** Builds the metric handles owned by the standalone auth process. */
export function initAuthOtel(env: { OTEL_EXPORTER_OTLP_ENDPOINT?: string, OTEL_SERVICE_NAME: string }): AuthOtelInstance | null {
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) {
@@ -40,13 +40,13 @@ export function initAuthOtel(env: { OTEL_EXPORTER_OTLP_ENDPOINT?: string, OTEL_S
const auth: AuthMetrics = {
attempts: meter.createCounter('auth.attempts', { description: 'Number of authentication attempts' }),
failures: meter.createCounter('auth.failures', { description: 'Number of failed authentication attempts' }),
userLogin: meter.createCounter('user.login', { description: 'Number of user sign-ins' }),
userRegistered: meter.createCounter('user.registered', { description: 'Number of new user registrations' }),
userLogin: meter.createCounter('user.login', { description: 'Number of user sign-ins' }),
}
const email: EmailMetrics = {
duration: meter.createHistogram('airi.email.duration', { description: 'Email provider call duration', unit: 's' }),
failures: meter.createCounter('airi.email.failures', { description: 'Transactional email send failures' }),
send: meter.createCounter('airi.email.send', { description: 'Transactional emails accepted by Resend' }),
failures: meter.createCounter('airi.email.failures', { description: 'Transactional email send failures' }),
duration: meter.createHistogram('airi.email.duration', { description: 'Email provider call duration', unit: 's' }),
}
const rateLimit: RateLimitMetrics = {
blocked: meter.createCounter('airi.rate_limit.blocked', { description: 'Requests blocked by the auth rate limiter' }),
@@ -66,11 +66,11 @@ export function initAuthOtel(env: { OTEL_EXPORTER_OTLP_ENDPOINT?: string, OTEL_S
const severityMap: Record<string, SeverityNumber> = {
debug: SeverityNumber.DEBUG,
error: SeverityNumber.ERROR,
info: SeverityNumber.INFO,
log: SeverityNumber.INFO,
verbose: SeverityNumber.TRACE,
log: SeverityNumber.INFO,
info: SeverityNumber.INFO,
warn: SeverityNumber.WARN,
error: SeverityNumber.ERROR,
}
/** Emits a log record through the auth process's global OTel provider. */
@@ -78,16 +78,16 @@ export function emitOtelLog(
level: string,
context: string,
message: string,
attributes?: Record<string, boolean | number | string>,
attributes?: Record<string, string | number | boolean>,
): void {
const spanContext = trace.getActiveSpan()?.spanContext()
logs.getLogger(context).emit({
attributes: {
...attributes,
...(spanContext && { span_id: spanContext.spanId, trace_id: spanContext.traceId }),
},
body: message,
severityNumber: severityMap[level.toLowerCase()] ?? SeverityNumber.INFO,
severityText: level.toUpperCase(),
body: message,
attributes: {
...attributes,
...(spanContext && { trace_id: spanContext.traceId, span_id: spanContext.spanId }),
},
})
}
+23 -23
View File
@@ -4,8 +4,8 @@ import { isUserBannedNow } from '@proj-airi/auth-shared'
import { APIError } from 'better-auth'
interface BanState {
banExpires?: Date | null | string
banned?: boolean | null
banExpires?: Date | string | null
}
/**
@@ -19,6 +19,28 @@ interface BanState {
export function banGuard(): BetterAuthPlugin {
return {
id: 'ban-guard',
schema: {
user: {
fields: {
banned: {
type: 'boolean',
defaultValue: false,
required: false,
input: false,
},
banReason: {
type: 'string',
required: false,
input: false,
},
banExpires: {
type: 'date',
required: false,
input: false,
},
},
},
},
init() {
return {
options: {
@@ -44,27 +66,5 @@ export function banGuard(): BetterAuthPlugin {
},
}
},
schema: {
user: {
fields: {
banExpires: {
input: false,
required: false,
type: 'date',
},
banned: {
defaultValue: false,
input: false,
required: false,
type: 'boolean',
},
banReason: {
input: false,
required: false,
type: 'string',
},
},
},
},
}
}
+14 -14
View File
@@ -91,15 +91,15 @@ export function oidcJwtBearer(env: AuthEnv): BetterAuthPlugin {
// Removal condition: never — local JWKS is the right primitive when the
// server *is* the IdP. Only revisit if jwks ever moves out of process.
const JWKS_TTL_MS = 60 * 1000
let cachedKeySet: null | ReturnType<typeof createLocalJWKSet> = null
let cachedKeySet: ReturnType<typeof createLocalJWKSet> | null = null
let cachedAt = 0
interface JwkRow {
id: string
publicKey: string
alg?: string
crv?: string
expiresAt?: Date | null
id: string
publicKey: string
}
/**
@@ -120,7 +120,7 @@ export function oidcJwtBearer(env: AuthEnv): BetterAuthPlugin {
*/
async function getOrLoadJWKS(
adapter: { findMany: (args: { model: string }) => Promise<unknown[]> },
): Promise<null | ReturnType<typeof createLocalJWKSet>> {
): Promise<ReturnType<typeof createLocalJWKSet> | null> {
if (cachedKeySet && Date.now() - cachedAt < JWKS_TTL_MS)
return cachedKeySet
@@ -182,9 +182,18 @@ export function oidcJwtBearer(env: AuthEnv): BetterAuthPlugin {
}
return {
id: 'oidc-jwt-bearer',
hooks: {
before: [
{
// Same matcher shape as bearer(). Run only when an Authorization
// header is present so we don't pay the cost on cookie-only flows.
matcher(context) {
return Boolean(
context.request?.headers.get('authorization')
?? context.headers?.get('authorization'),
)
},
handler: createAuthMiddleware(async (c) => {
const incomingHeaders = c.request?.headers ?? c.headers
if (!incomingHeaders)
@@ -222,8 +231,8 @@ export function oidcJwtBearer(env: AuthEnv): BetterAuthPlugin {
let userId: string
try {
const { payload } = await jwtVerify(token, keySet, {
audience: env.PUBLIC_URL,
issuer: `${env.PUBLIC_URL}/api/auth`,
audience: env.PUBLIC_URL,
})
if (typeof payload.sub !== 'string')
return
@@ -264,17 +273,8 @@ export function oidcJwtBearer(env: AuthEnv): BetterAuthPlugin {
return { context: { headers: newHeaders } }
}),
// Same matcher shape as bearer(). Run only when an Authorization
// header is present so we don't pay the cost on cookie-only flows.
matcher(context) {
return Boolean(
context.request?.headers.get('authorization')
?? context.headers?.get('authorization'),
)
},
},
],
},
id: 'oidc-jwt-bearer',
}
}
+19 -19
View File
@@ -22,13 +22,13 @@ const STEAM_CLAIMED_ID_PATTERN = /^https:\/\/steamcommunity\.com\/openid\/id\/(\
// better-auth's OpenAPI generation supports non-Zod schemas.
const SignInBodySchema = z.object({
callbackURL: z.string().meta({ description: 'The URL to redirect to after sign in' }),
disableRedirect: z.boolean().optional(),
errorCallbackURL: z.string().meta({ description: 'The URL to redirect to if an error occurs' }).optional(),
disableRedirect: z.boolean().optional(),
})
const CallbackQuerySchema = z.looseObject({
'openid.mode': z.string().optional(),
'state': z.string().optional(),
'openid.mode': z.string().optional(),
})
/**
@@ -88,9 +88,9 @@ export function steam() {
try {
const body = await ofetch<string, 'text'>(STEAM_OPENID_ENDPOINT, {
body: verifyParams.toString(),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: verifyParams.toString(),
responseType: 'text',
timeout: 10_000,
})
@@ -105,60 +105,60 @@ export function steam() {
}
const signInSteam = createAuthEndpoint('/sign-in/steam', {
method: 'POST',
body: SignInBodySchema,
metadata: {
openapi: {
description: 'Start Steam OpenID sign-in',
responses: {
200: {
content: { 'application/json': { schema: { properties: { redirect: { type: 'boolean' }, url: { type: 'string' } }, type: 'object' } } },
description: 'Redirect URL to Steam OpenID login',
content: { 'application/json': { schema: { type: 'object', properties: { url: { type: 'string' }, redirect: { type: 'boolean' } } } } },
},
},
},
},
method: 'POST',
}, async (ctx) => {
const { state } = await generateState(ctx, undefined, undefined)
return ctx.json({
redirect: !ctx.body.disableRedirect,
url: buildOpenIdRedirectURL(ctx.context.baseURL, state),
redirect: !ctx.body.disableRedirect,
})
})
const linkSteam = createAuthEndpoint('/link/steam', {
method: 'POST',
body: SignInBodySchema,
use: [sessionMiddleware],
metadata: {
openapi: {
description: 'Link the current user to a Steam account',
responses: {
200: {
content: { 'application/json': { schema: { properties: { redirect: { type: 'boolean' }, url: { type: 'string' } }, type: 'object' } } },
description: 'Redirect URL to Steam OpenID login',
content: { 'application/json': { schema: { type: 'object', properties: { url: { type: 'string' }, redirect: { type: 'boolean' } } } } },
},
},
},
},
method: 'POST',
use: [sessionMiddleware],
}, async (ctx) => {
const session = ctx.context.session
const { state } = await generateState(ctx, { email: session.user.email, userId: session.user.id }, undefined)
const { state } = await generateState(ctx, { userId: session.user.id, email: session.user.email }, undefined)
return ctx.json({
redirect: !ctx.body.disableRedirect,
url: buildOpenIdRedirectURL(ctx.context.baseURL, state),
redirect: !ctx.body.disableRedirect,
})
})
const steamCallback = createAuthEndpoint('/steam/callback', {
method: 'GET',
query: CallbackQuerySchema,
metadata: {
openapi: {
description: 'Steam OpenID callback',
responses: { 200: { description: 'Redirects to callbackURL or errorURL' } },
},
},
method: 'GET',
query: CallbackQuerySchema,
}, async (ctx) => {
const parsedState = await parseState(ctx)
const callbackURL = parsedState.callbackURL
@@ -193,9 +193,9 @@ export function steam() {
if (!existingAccount) {
await ctx.context.internalAdapter.linkAccount({
accountId: steamId,
providerId: 'steam',
userId: link.userId,
providerId: 'steam',
accountId: steamId,
})
}
throw ctx.redirect(callbackURL)
@@ -212,7 +212,7 @@ export function steam() {
emailVerified: true,
name: `Steam User ${steamId}`,
},
{ accountId: steamId, providerId: 'steam' },
{ providerId: 'steam', accountId: steamId },
)
userId = user.id
}
@@ -227,11 +227,11 @@ export function steam() {
})
return {
id: 'steam',
endpoints: {
linkSteam,
signInSteam,
linkSteam,
steamCallback,
},
id: 'steam',
}
}
+18 -18
View File
@@ -9,10 +9,18 @@ import { getConnInfo } from '@hono/node-server/conninfo'
import { rateLimiter as createRateLimiter } from 'hono-rate-limiter'
interface RateLimitOptions {
/** Key generator: extracts a unique identifier from the request */
keyGenerator?: (c: Context<HonoEnv>) => string
/** Max requests allowed within the window */
max: number
/** Window size in seconds */
windowSec: number
/** Key generator: extracts a unique identifier from the request */
keyGenerator?: (c: Context<HonoEnv>) => string
/**
* Reverse proxy whose client-address header is safe to use. The caller must
* select this only when the deployment guarantees that the named proxy owns
* and overwrites that header before the request reaches the application.
*/
trustedProxy?: 'railway'
/**
* Optional metrics handle. When provided, blocked requests increment
* `airi_rate_limit_blocked_total{route, key_type, limit}`.
@@ -20,21 +28,13 @@ interface RateLimitOptions {
* or remote IP — important for distinguishing logged-in abuse from
* anonymous scraping.
*/
metrics?: null | RateLimitMetrics
metrics?: RateLimitMetrics | null
/**
* Stable label for the route this limiter guards (e.g. `auth.api`,
* `openai.completions`, `stripe.checkout`). Avoids high-cardinality URL
* paths in metric labels.
*/
routeLabel?: string
/**
* Reverse proxy whose client-address header is safe to use. The caller must
* select this only when the deployment guarantees that the named proxy owns
* and overwrites that header before the request reaches the application.
*/
trustedProxy?: 'railway'
/** Window size in seconds */
windowSec: number
}
/**
@@ -64,23 +64,23 @@ export function rateLimiter(opts: RateLimitOptions) {
})
return createRateLimiter<HonoEnv>({
windowMs: opts.windowSec * 1000,
limit: opts.max,
// NOTICE: draft-6 keeps the widely supported RateLimit-* header set.
// Later drafts use combined formats that existing clients may not parse.
standardHeaders: 'draft-6',
keyGenerator: keyGen,
handler: (c) => {
// Record the block before producing the response so later response
// changes cannot remove the metric.
const keyType = c.get('user')?.id ? 'user' : 'ip'
opts.metrics?.blocked.add(1, {
route: opts.routeLabel ?? 'unknown',
key_type: keyType,
limit: String(opts.max),
route: opts.routeLabel ?? 'unknown',
})
return c.json({ error: 'TOO_MANY_REQUESTS', message: 'Too many requests' }, 429)
},
keyGenerator: keyGen,
limit: opts.max,
// NOTICE: draft-6 keeps the widely supported RateLimit-* header set.
// Later drafts use combined formats that existing clients may not parse.
standardHeaders: 'draft-6',
windowMs: opts.windowSec * 1000,
})
}
+9 -9
View File
@@ -2,10 +2,12 @@ import { useLogger } from '@guiiai/logg'
import { createBadGatewayError } from './error'
export type UserDeletionReason = 'user-requested' | 'admin' | 'compliance'
export interface AuthEventInput {
userId: string
action: 'user_signed_up'
source: 'better-auth.user.create'
userId: string
}
/**
@@ -14,12 +16,10 @@ export interface AuthEventInput {
* expose `/internal/*`.
*/
export interface ResourceApi {
softDeleteUserData: (input: { reason: UserDeletionReason, userId: string }) => Promise<void>
softDeleteUserData: (input: { userId: string, reason: UserDeletionReason }) => Promise<void>
trackAuthEvent: (input: AuthEventInput) => Promise<void>
}
export type UserDeletionReason = 'admin' | 'compliance' | 'user-requested'
/** Creates the single private HTTP boundary from Auth to the resource API. */
export function createResourceApi(
resourceServerUrl: string,
@@ -30,9 +30,9 @@ export function createResourceApi(
return {
async softDeleteUserData(input) {
const response = await fetchRequest(new URL('/internal/auth/user-deletion', resourceServerUrl), {
body: JSON.stringify(input),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
if (!response.ok) {
@@ -45,12 +45,12 @@ export function createResourceApi(
async trackAuthEvent(input) {
try {
const response = await fetchRequest(new URL('/internal/auth/events', resourceServerUrl), {
body: JSON.stringify(input),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
if (!response.ok)
logger.withFields({ action: input.action, statusCode: response.status }).warn('Resource API rejected auth event')
logger.withFields({ statusCode: response.status, action: input.action }).warn('Resource API rejected auth event')
}
catch (error) {
// Analytics must never make signup or login unavailable.
+107 -107
View File
@@ -19,8 +19,8 @@ import { rateLimiter } from './rate-limit'
export interface HonoEnv {
Variables: {
session: AuthSession['session'] | null
user: AuthSession['user'] | null
session: AuthSession['session'] | null
}
}
@@ -30,20 +30,7 @@ export const DEFAULT_AUTH_UI_URL = 'https://accounts.airi.build/ui'
export const SERVER_DEV_PUBLIC_URL = 'https://airi-server-dev.up.railway.app'
export const SERVER_DEV_AUTH_UI_URL = 'https://server-dev.airi-server-auth.pages.dev/ui'
const FORWARDED_AUTH_UI_PROVIDERS = new Set(['github', 'google', 'steam'])
/** Maps a public `/auth/*` request to its standalone Auth UI URL. */
export function buildAuthUiRedirectUrl(authUiUrl: string, requestUrl: string, apiServerUrl?: string): string {
const request = new URL(requestUrl)
const suffix = request.pathname === SERVER_AUTH_UI_BASE_PATH
? '/'
: request.pathname.slice(SERVER_AUTH_UI_BASE_PATH.length) || '/'
const resolvedAuthUiUrl = apiServerUrl ? resolveAuthUiUrl(authUiUrl, apiServerUrl) : authUiUrl
const target = new URL(buildAuthUiUrl(resolvedAuthUiUrl, suffix, request.search))
if (apiServerUrl)
target.searchParams.set(AUTH_UI_PUBLIC_URL_QUERY_PARAM, new URL(apiServerUrl).origin)
return target.toString()
}
const FORWARDED_AUTH_UI_PROVIDERS = new Set(['google', 'github', 'steam'])
/** Builds a route URL below the configured standalone Auth UI base. */
export function buildAuthUiUrl(authUiUrl: string, path: string, search = ''): string {
@@ -73,6 +60,19 @@ export function resolveAuthUiUrl(authUiUrl: string, apiServerUrl: string): strin
return authUiUrl
}
/** Maps a public `/auth/*` request to its standalone Auth UI URL. */
export function buildAuthUiRedirectUrl(authUiUrl: string, requestUrl: string, apiServerUrl?: string): string {
const request = new URL(requestUrl)
const suffix = request.pathname === SERVER_AUTH_UI_BASE_PATH
? '/'
: request.pathname.slice(SERVER_AUTH_UI_BASE_PATH.length) || '/'
const resolvedAuthUiUrl = apiServerUrl ? resolveAuthUiUrl(authUiUrl, apiServerUrl) : authUiUrl
const target = new URL(buildAuthUiUrl(resolvedAuthUiUrl, suffix, request.search))
if (apiServerUrl)
target.searchParams.set(AUTH_UI_PUBLIC_URL_QUERY_PARAM, new URL(apiServerUrl).origin)
return target.toString()
}
/** Restores a trusted mobile provider hint after the OIDC plugin builds its sign-in redirect. */
function forwardAuthUiProviderHint(requestUrl: string, response: Response): Response {
const request = new URL(requestUrl)
@@ -91,23 +91,21 @@ function forwardAuthUiProviderHint(requestUrl: string, response: Response): Resp
const headers = new Headers(response.headers)
headers.set('location', location.startsWith('/') ? `${target.pathname}${target.search}${target.hash}` : target.toString())
return new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
headers,
})
}
const remoteJwksByUrl = new Map<string, ReturnType<typeof createRemoteJWKSet>>()
function buildGravatarUrl(emailAddress: string): null | string {
const normalized = emailAddress.trim().toLowerCase()
if (!normalized)
function readBearerToken(headers: Headers): string | null {
const authorization = headers.get('authorization')
if (!authorization?.startsWith('Bearer '))
return null
const url = new URL(createHash('sha256').update(normalized).digest('hex'), 'https://www.gravatar.com/avatar/')
url.searchParams.set('d', 'identicon')
url.searchParams.set('s', '200')
return url.toString()
const token = authorization.slice(7).trim()
return token.length > 0 ? token : null
}
function getRemoteJwks(publicUrl: string): ReturnType<typeof createRemoteJWKSet> {
@@ -121,28 +119,6 @@ function getRemoteJwks(publicUrl: string): ReturnType<typeof createRemoteJWKSet>
return jwks
}
function readBearerToken(headers: Headers): null | string {
const authorization = headers.get('authorization')
if (!authorization?.startsWith('Bearer '))
return null
const token = authorization.slice(7).trim()
return token.length > 0 ? token : null
}
async function resolveAuthRequest(
auth: AuthInstance,
db: AuthDatabase,
env: Pick<AuthEnv, 'PUBLIC_URL'>,
headers: Headers,
): Promise<AuthSession | null> {
const resolved = await resolveSessionIgnoringBan(auth, db, env, headers)
if (!resolved || isUserBannedNow(resolved.user))
return null
return resolved
}
async function resolveJwtAccessToken(
db: AuthDatabase,
env: Pick<AuthEnv, 'PUBLIC_URL'>,
@@ -150,8 +126,8 @@ async function resolveJwtAccessToken(
): Promise<AuthSession | null> {
try {
const { payload } = await jwtVerify(accessToken, getRemoteJwks(env.PUBLIC_URL), {
audience: env.PUBLIC_URL,
issuer: `${env.PUBLIC_URL}/api/auth`,
audience: env.PUBLIC_URL,
})
if (!payload.sub)
return null
@@ -164,17 +140,17 @@ async function resolveJwtAccessToken(
const issuedAt = payload.iat ? new Date(payload.iat * 1000) : new Date()
return {
session: {
createdAt: issuedAt,
expiresAt: payload.exp ? new Date(payload.exp * 1000) : new Date(),
id: payload.jti ?? payload.sub,
ipAddress: null,
token: accessToken,
updatedAt: issuedAt,
userAgent: null,
userId: payload.sub,
},
user: resolvedUser,
session: {
id: payload.jti ?? payload.sub,
token: accessToken,
userId: payload.sub,
createdAt: issuedAt,
updatedAt: issuedAt,
expiresAt: payload.exp ? new Date(payload.exp * 1000) : new Date(),
ipAddress: null,
userAgent: null,
},
}
}
catch {
@@ -199,6 +175,30 @@ async function resolveSessionIgnoringBan(
return await resolveJwtAccessToken(db, env, accessToken)
}
async function resolveAuthRequest(
auth: AuthInstance,
db: AuthDatabase,
env: Pick<AuthEnv, 'PUBLIC_URL'>,
headers: Headers,
): Promise<AuthSession | null> {
const resolved = await resolveSessionIgnoringBan(auth, db, env, headers)
if (!resolved || isUserBannedNow(resolved.user))
return null
return resolved
}
function buildGravatarUrl(emailAddress: string): string | null {
const normalized = emailAddress.trim().toLowerCase()
if (!normalized)
return null
const url = new URL(createHash('sha256').update(normalized).digest('hex'), 'https://www.gravatar.com/avatar/')
url.searchParams.set('d', 'identicon')
url.searchParams.set('s', '200')
return url.toString()
}
const CheckEmailIdentifierBodySchema = object({
email: pipe(
string(),
@@ -208,11 +208,57 @@ const CheckEmailIdentifierBodySchema = object({
),
})
async function checkEmailIdentifier(db: AuthDatabase, body: { email?: unknown } | null) {
const parsed = safeParse(CheckEmailIdentifierBodySchema, body)
if (!parsed.success)
throw createBadRequestError('Invalid email', 'INVALID_EMAIL')
const [matched] = await db.select({ id: user.id }).from(user).where(eq(user.email, parsed.output.email)).limit(1)
if (!matched)
return { exists: false, hasPassword: false }
const [credential] = await db
.select({ id: account.id })
.from(account)
.where(and(eq(account.userId, matched.id), eq(account.providerId, 'credential')))
.limit(1)
return { exists: true, hasPassword: !!credential }
}
function createAuthUiRoutes(env: AuthEnv) {
return new Hono<HonoEnv>()
.get(SERVER_AUTH_UI_BASE_PATH, c => c.redirect(buildAuthUiRedirectUrl(env.AUTH_UI_URL, c.req.url, env.PUBLIC_URL)))
.get(`${SERVER_AUTH_UI_BASE_PATH}/*`, c => c.redirect(buildAuthUiRedirectUrl(env.AUTH_UI_URL, c.req.url, env.PUBLIC_URL)))
}
function createElectronCallbackRelay(env: AuthEnv) {
return new Hono<HonoEnv>().get('/', (c) => {
const request = new URL(c.req.url)
return c.redirect(buildAuthUiUrl(env.AUTH_UI_URL, '/api/auth/oidc/electron-callback', request.search))
})
}
function createOIDCTokenAuthRoute(deps: Pick<AuthRoutesDeps, 'auth' | 'db' | 'env'>) {
return new Hono<HonoEnv>()
.on(['GET', 'POST'], '/get-session', async (c) => {
const session = await resolveAuthRequest(deps.auth, deps.db, deps.env, c.req.raw.headers)
if (!session)
return c.json(null)
const image = session.user.image || buildGravatarUrl(session.user.email)
return c.json({ ...session, user: { ...session.user, image } })
})
.post('/sign-out', c => c.json({ success: true }))
.get('/list-sessions', async (c) => {
const session = await resolveAuthRequest(deps.auth, deps.db, deps.env, c.req.raw.headers)
return c.json(session ? [session.session] : [])
})
}
export interface AuthRoutesDeps {
auth: AuthInstance
db: AuthDatabase
env: AuthEnv
rateLimitMetrics?: null | RateLimitMetrics
rateLimitMetrics?: RateLimitMetrics | null
}
/**
@@ -242,12 +288,12 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) {
*/
.use('/api/auth/*', rateLimiter({
max: 20,
metrics: deps.rateLimitMetrics,
routeLabel: 'auth.api',
windowSec: 60,
// Proxy trust is a deployment boundary, not a property of the public
// API URL. Custom domains and private gateways must opt in explicitly.
trustedProxy: deps.env.RATE_LIMIT_TRUSTED_PROXY,
windowSec: 60,
metrics: deps.rateLimitMetrics,
routeLabel: 'auth.api',
}))
.all('/api/auth/admin', c => c.notFound())
.all('/api/auth/admin/*', c => c.notFound())
@@ -315,56 +361,10 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) {
* per-IP request limit to `/api/auth/*` and throttles enumeration attempts.
*/
.on('POST', '/api/auth/check-email', async (c) => {
const body = await c.req.json().catch(() => null) as null | { email?: unknown }
const body = await c.req.json().catch(() => null) as { email?: unknown } | null
return c.json(await checkEmailIdentifier(deps.db, body))
})
.on(['POST', 'GET'], '/api/auth/*', async (c) => {
return handleAuthRequest(c.req.raw)
})
}
async function checkEmailIdentifier(db: AuthDatabase, body: null | { email?: unknown }) {
const parsed = safeParse(CheckEmailIdentifierBodySchema, body)
if (!parsed.success)
throw createBadRequestError('Invalid email', 'INVALID_EMAIL')
const [matched] = await db.select({ id: user.id }).from(user).where(eq(user.email, parsed.output.email)).limit(1)
if (!matched)
return { exists: false, hasPassword: false }
const [credential] = await db
.select({ id: account.id })
.from(account)
.where(and(eq(account.userId, matched.id), eq(account.providerId, 'credential')))
.limit(1)
return { exists: true, hasPassword: !!credential }
}
function createAuthUiRoutes(env: AuthEnv) {
return new Hono<HonoEnv>()
.get(SERVER_AUTH_UI_BASE_PATH, c => c.redirect(buildAuthUiRedirectUrl(env.AUTH_UI_URL, c.req.url, env.PUBLIC_URL)))
.get(`${SERVER_AUTH_UI_BASE_PATH}/*`, c => c.redirect(buildAuthUiRedirectUrl(env.AUTH_UI_URL, c.req.url, env.PUBLIC_URL)))
}
function createElectronCallbackRelay(env: AuthEnv) {
return new Hono<HonoEnv>().get('/', (c) => {
const request = new URL(c.req.url)
return c.redirect(buildAuthUiUrl(env.AUTH_UI_URL, '/api/auth/oidc/electron-callback', request.search))
})
}
function createOIDCTokenAuthRoute(deps: Pick<AuthRoutesDeps, 'auth' | 'db' | 'env'>) {
return new Hono<HonoEnv>()
.on(['GET', 'POST'], '/get-session', async (c) => {
const session = await resolveAuthRequest(deps.auth, deps.db, deps.env, c.req.raw.headers)
if (!session)
return c.json(null)
const image = session.user.image || buildGravatarUrl(session.user.email)
return c.json({ ...session, user: { ...session.user, image } })
})
.post('/sign-out', c => c.json({ success: true }))
.get('/list-sessions', async (c) => {
const session = await resolveAuthRequest(deps.auth, deps.db, deps.env, c.req.raw.headers)
return c.json(session ? [session.session] : [])
})
}
+49 -49
View File
@@ -32,16 +32,39 @@ import { createAuthRoutes } from './routes'
const EXTERNAL_DEPENDENCY_INIT_MAX_ATTEMPTS = 5
const EXTERNAL_DEPENDENCY_INIT_BASE_DELAY_MS = 5000
/** Initializes an Auth dependency using the process startup retry policy. */
async function initializeExternalDependency<T>(
dependencyName: string,
logger: Logger,
initialize: (attempt: number) => Promise<T>,
): Promise<T> {
let attempt = 0
return await withRetry(
async () => {
attempt += 1
return await initialize(attempt)
},
{
retry: EXTERNAL_DEPENDENCY_INIT_MAX_ATTEMPTS - 1,
retryDelay: EXTERNAL_DEPENDENCY_INIT_BASE_DELAY_MS,
retryDelayFactor: 2,
retryDelayMax: EXTERNAL_DEPENDENCY_INIT_BASE_DELAY_MS * 2 ** (EXTERNAL_DEPENDENCY_INIT_MAX_ATTEMPTS - 1),
onError: (error) => {
logger.withError(error).warn(`${dependencyName} initialization failed on attempt ${attempt}/${EXTERNAL_DEPENDENCY_INIT_MAX_ATTEMPTS}`)
},
},
)()
}
export interface AuthAppDeps {
auth: AuthInstance
db: AuthDatabase
env: AuthEnv
rateLimitMetrics?: null | RateLimitMetrics
redis: Redis
env: AuthEnv
rateLimitMetrics?: RateLimitMetrics | null
}
export type AuthAppType = Awaited<ReturnType<typeof buildAuthApp>>['app']
/** Builds the standalone Auth HTTP surface without constructing its runtime dependencies. */
export async function buildAuthApp(deps: AuthAppDeps) {
const logger = useLogger('auth-app').useGlobalConfig()
@@ -56,24 +79,24 @@ export async function buildAuthApp(deps: AuthAppDeps) {
.use(
'/api/*',
cors({
credentials: true,
origin: origin => getTrustedOrigin(origin, deps.env.ADDITIONAL_TRUSTED_ORIGINS),
credentials: true,
}),
)
.use(honoLogger())
.use('*', bodyLimit({ maxSize: 1024 * 1024 }))
.onError((err, c) => {
if (err instanceof ApiError) {
const logFields = { cause: (err as { cause?: unknown }).cause, details: err.details }
const logFields = { details: err.details, cause: (err as { cause?: unknown }).cause }
if (err.statusCode >= 500)
logger.withError(err).withFields(logFields).error('Auth API error occurred')
else if (err.statusCode !== 401)
logger.withError(err).withFields(logFields).warn('Auth API error occurred')
return c.json({
details: err.details,
error: err.errorCode,
message: err.message,
details: err.details,
}, err.statusCode)
}
@@ -97,14 +120,14 @@ export async function buildAuthApp(deps: AuthAppDeps) {
const ready = dbReady && redisReady
return c.json({
checks: { db: dbReady ? 'ok' : 'fail', redis: redisReady ? 'ok' : 'fail' },
status: ready ? 'ready' : 'not_ready',
checks: { db: dbReady ? 'ok' : 'fail', redis: redisReady ? 'ok' : 'fail' },
}, ready ? 200 : 503)
})
.get('/', c => c.json({
accounts: deps.env.AUTH_UI_URL,
issuer: `${deps.env.PUBLIC_URL}/api/auth`,
service: 'airi-auth',
issuer: `${deps.env.PUBLIC_URL}/api/auth`,
accounts: deps.env.AUTH_UI_URL,
}))
.route('/', await createAuthRoutes({
auth: deps.auth,
@@ -116,6 +139,8 @@ export async function buildAuthApp(deps: AuthAppDeps) {
return { app }
}
export type AuthAppType = Awaited<ReturnType<typeof buildAuthApp>>['app']
/**
* Builds the standalone auth runtime with its own dependency container.
* Only authentication infrastructure is registered here; business services
@@ -127,15 +152,16 @@ export async function createAuthServer() {
const container = createContainer({ logger: createLoggLogger(useLogger('injeca').useGlobalConfig()) })
setGlobalHookPostLog((log) => {
emitOtelLog(log.level, log.context, log.message, log.fields as Record<string, boolean | number | string>)
emitOtelLog(log.level, log.context, log.message, log.fields as Record<string, string | number | boolean>)
})
const env = provide(container, 'env', () => parseAuthEnv(process.env))
const otel = provide(container, 'libs:otel', {
build: ({ dependsOn }) => initAuthOtel(dependsOn.env),
dependsOn: { env },
build: ({ dependsOn }) => initAuthOtel(dependsOn.env),
})
const db = provide(container, 'datastore:db', {
dependsOn: { env, lifecycle },
build: async ({ dependsOn }) => {
const connection = await initializeExternalDependency('Database', logger, async (attempt) => {
const candidate = createAuthDrizzle(dependsOn.env)
@@ -154,9 +180,9 @@ export async function createAuthServer() {
dependsOn.lifecycle.appHooks.onStop(() => connection.pool.end())
return connection.db
},
dependsOn: { env, lifecycle },
})
const redis = provide(container, 'datastore:redis', {
dependsOn: { env, lifecycle },
build: async ({ dependsOn }) => {
const instance = await initializeExternalDependency('Redis', logger, async (attempt) => {
const candidate = new Redis(dependsOn.env.REDIS_URL, { lazyConnect: true })
@@ -175,21 +201,21 @@ export async function createAuthServer() {
})
return instance
},
dependsOn: { env, lifecycle },
})
const email = provide(container, 'services:email', {
dependsOn: { env, otel },
build: ({ dependsOn }) => createEmailService({
apiKey: dependsOn.env.RESEND_API_KEY,
fromEmail: dependsOn.env.RESEND_FROM_EMAIL,
fromName: dependsOn.env.RESEND_FROM_NAME,
}, undefined, dependsOn.otel?.email),
dependsOn: { env, otel },
})
const resourceApi = provide(container, 'services:resourceApi', {
build: ({ dependsOn }) => createResourceApi(dependsOn.env.RESOURCE_SERVER_URL),
dependsOn: { env },
build: ({ dependsOn }) => createResourceApi(dependsOn.env.RESOURCE_SERVER_URL),
})
const auth = provide(container, 'services:auth', {
dependsOn: { db, env, email, otel, resourceApi },
build: async ({ dependsOn }) => {
await seedTrustedClients(dependsOn.db, dependsOn.env)
for (const client of getTrustedClientSeedSummaries(dependsOn.env)) {
@@ -207,18 +233,17 @@ export async function createAuthServer() {
dependsOn.resourceApi,
)
},
dependsOn: { db, email, env, otel, resourceApi },
})
await start(container)
const dependencies = await resolve(container, { auth, db, env, otel, redis })
const dependencies = await resolve(container, { auth, db, redis, env, otel })
const { app } = await buildAuthApp({
auth: dependencies.auth,
db: dependencies.db,
redis: dependencies.redis,
env: dependencies.env,
rateLimitMetrics: dependencies.otel?.rateLimit,
redis: dependencies.redis,
})
return {
@@ -229,6 +254,10 @@ export async function createAuthServer() {
}
}
function handleProcessError(error: unknown, type: string) {
useLogger().withError(error).error(type)
}
/**
* Starts the dedicated Auth HTTP process and owns its shutdown lifecycle.
*
@@ -241,7 +270,7 @@ export async function createAuthServer() {
*/
export async function runAuthServer(): Promise<void> {
const runtime = await createAuthServer()
const server = serve({ fetch: runtime.app.fetch, hostname: runtime.hostname, port: runtime.port })
const server = serve({ fetch: runtime.app.fetch, port: runtime.port, hostname: runtime.hostname })
process.on('uncaughtException', error => handleProcessError(error, 'Uncaught exception'))
process.on('unhandledRejection', error => handleProcessError(error, 'Unhandled rejection'))
@@ -251,32 +280,3 @@ export async function runAuthServer(): Promise<void> {
server.once('error', error => reject(error))
}).finally(runtime.stop)
}
function handleProcessError(error: unknown, type: string) {
useLogger().withError(error).error(type)
}
/** Initializes an Auth dependency using the process startup retry policy. */
async function initializeExternalDependency<T>(
dependencyName: string,
logger: Logger,
initialize: (attempt: number) => Promise<T>,
): Promise<T> {
let attempt = 0
return await withRetry(
async () => {
attempt += 1
return await initialize(attempt)
},
{
onError: (error) => {
logger.withError(error).warn(`${dependencyName} initialization failed on attempt ${attempt}/${EXTERNAL_DEPENDENCY_INIT_MAX_ATTEMPTS}`)
},
retry: EXTERNAL_DEPENDENCY_INIT_MAX_ATTEMPTS - 1,
retryDelay: EXTERNAL_DEPENDENCY_INIT_BASE_DELAY_MS,
retryDelayFactor: 2,
retryDelayMax: EXTERNAL_DEPENDENCY_INIT_BASE_DELAY_MS * 2 ** (EXTERNAL_DEPENDENCY_INIT_MAX_ATTEMPTS - 1),
},
)()
}
+116 -116
View File
@@ -11,16 +11,16 @@ import * as authSchema from '@proj-airi/auth-shared'
import { createBadGatewayError, createServiceUnavailableError } from './error'
type AppleCredentials = Pick<AuthEnv, 'AUTH_APPLE_CLIENT_ID' | 'AUTH_APPLE_KEY_ID' | 'AUTH_APPLE_PRIVATE_KEY_PEM' | 'AUTH_APPLE_TEAM_ID'>
interface SocialAccount {
accessToken: null | string
providerId: string
refreshToken: null | string
}
type AppleCredentials = Pick<AuthEnv, 'AUTH_APPLE_CLIENT_ID' | 'AUTH_APPLE_TEAM_ID' | 'AUTH_APPLE_KEY_ID' | 'AUTH_APPLE_PRIVATE_KEY_PEM'>
type SocialAuthorizationCredentials = AppleCredentials & Pick<AuthEnv, 'AUTH_GITHUB_CLIENT_ID' | 'AUTH_GITHUB_CLIENT_SECRET'>
interface SocialAccount {
providerId: string
accessToken: string | null
refreshToken: string | null
}
const GoogleInvalidTokenResponseSchema = object({
error: literal('invalid_token'),
})
@@ -50,77 +50,7 @@ export async function createAppleClientSecret(credentials: AppleCredentials): Pr
.sign(key)
}
/**
* Creates the provider-aware authorization boundary used by account deletion.
*
* Every configured social provider must have an explicit revocation policy.
* Unknown providers abort deletion so a future login integration cannot
* silently regress to deleting only AIRI's local account records.
*
* @see https://developer.apple.com/documentation/signinwithapplerestapi/revoke-tokens
* @see https://developers.google.com/identity/protocols/oauth2/web-server#tokenrevoke
* @see https://docs.github.com/en/rest/apps/oauth-applications#delete-an-app-authorization
*/
export function createSocialAuthorizationRevoker(
db: AuthDatabase,
credentials: SocialAuthorizationCredentials,
fetchRequest: typeof fetch = fetch,
): SocialAuthorizationRevoker {
return {
async revokeForUser(userId) {
const accounts = await db
.select({
accessToken: authSchema.account.accessToken,
providerId: authSchema.account.providerId,
refreshToken: authSchema.account.refreshToken,
})
.from(authSchema.account)
.where(eq(authSchema.account.userId, userId))
for (const account of accounts) {
if (account.providerId === 'credential')
continue
if (account.providerId === 'apple') {
await revokeAppleAuthorization(account, credentials, fetchRequest)
continue
}
if (account.providerId === 'google') {
await revokeGoogleAuthorization(account, fetchRequest)
continue
}
if (account.providerId === 'github') {
await revokeGitHubAuthorization(account, credentials, fetchRequest)
continue
}
throw createServiceUnavailableError(
`Authorization revocation is not implemented for ${account.providerId}.`,
'oauth/revocation_not_supported',
{ providerId: account.providerId },
)
}
},
}
}
function githubHeaders(credentials: SocialAuthorizationCredentials): Record<string, string> {
return {
'Accept': 'application/vnd.github+json',
'Authorization': `Basic ${Buffer.from(`${credentials.AUTH_GITHUB_CLIENT_ID}:${credentials.AUTH_GITHUB_CLIENT_SECRET}`).toString('base64')}`,
'Content-Type': 'application/json',
'X-GitHub-Api-Version': '2022-11-28',
}
}
async function isInactiveGoogleToken(response: Response): Promise<boolean> {
if (response.status !== 400)
return false
const body = await response.json().catch(() => undefined)
return safeParse(GoogleInvalidTokenResponseSchema, body).success
}
function revocationToken(account: SocialAccount): { token: string, tokenType: 'access_token' | 'refresh_token' } {
function revocationToken(account: SocialAccount): { token: string, tokenType: 'refresh_token' | 'access_token' } {
if (account.refreshToken)
return { token: account.refreshToken, tokenType: 'refresh_token' }
if (account.accessToken)
@@ -152,14 +82,14 @@ async function revokeAppleAuthorization(
const { token, tokenType } = revocationToken(account)
const clientSecret = await createAppleClientSecret(credentials)
const response = await fetchRequest('https://appleid.apple.com/auth/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: credentials.AUTH_APPLE_CLIENT_ID,
client_secret: clientSecret,
token,
token_type_hint: tokenType,
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
method: 'POST',
})
if (!response.ok) {
@@ -170,6 +100,59 @@ async function revokeAppleAuthorization(
}
}
async function isInactiveGoogleToken(response: Response): Promise<boolean> {
if (response.status !== 400)
return false
const body = await response.json().catch(() => undefined)
return safeParse(GoogleInvalidTokenResponseSchema, body).success
}
async function revokeGoogleToken(token: string, fetchRequest: typeof fetch): Promise<'revoked' | 'inactive'> {
const response = await fetchRequest('https://oauth2.googleapis.com/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ token }),
})
// Google reports expired and previously revoked tokens as invalid_token.
// Either state means the saved credential can no longer authorize AIRI, so
// accepting it makes a partially completed deletion safe to retry.
if (response.ok)
return 'revoked'
if (await isInactiveGoogleToken(response))
return 'inactive'
throw createBadGatewayError('Google authorization revocation failed.', {
providerId: 'google',
statusCode: response.status,
})
}
async function revokeGoogleAuthorization(account: SocialAccount, fetchRequest: typeof fetch): Promise<void> {
const { token, tokenType } = revocationToken(account)
const result = await revokeGoogleToken(token, fetchRequest)
// An inactive refresh token cannot revoke a still-live access token. Try the
// separately retained access token before accepting the authorization as
// gone; Google links a successful access-token revocation back to its grant.
if (result === 'inactive'
&& tokenType === 'refresh_token'
&& account.accessToken
&& account.accessToken !== token) {
await revokeGoogleToken(account.accessToken, fetchRequest)
}
}
function githubHeaders(credentials: SocialAuthorizationCredentials): Record<string, string> {
return {
'Accept': 'application/vnd.github+json',
'Authorization': `Basic ${Buffer.from(`${credentials.AUTH_GITHUB_CLIENT_ID}:${credentials.AUTH_GITHUB_CLIENT_SECRET}`).toString('base64')}`,
'Content-Type': 'application/json',
'X-GitHub-Api-Version': '2022-11-28',
}
}
async function revokeGitHubAuthorization(
account: SocialAccount,
credentials: SocialAuthorizationCredentials,
@@ -194,9 +177,9 @@ async function revokeGitHubAuthorization(
const body = JSON.stringify({ access_token: account.accessToken })
const applicationsUrl = `https://api.github.com/applications/${encodeURIComponent(credentials.AUTH_GITHUB_CLIENT_ID)}`
const response = await fetchRequest(`${applicationsUrl}/grant`, {
body,
headers,
method: 'DELETE',
headers,
body,
})
if (response.status === 204)
@@ -206,9 +189,9 @@ async function revokeGitHubAuthorization(
// status. Check the token after any failed delete: 404 is the documented
// invalid-token response and proves that the grant can no longer be used.
const verificationResponse = await fetchRequest(`${applicationsUrl}/token`, {
body,
headers,
method: 'POST',
headers,
body,
})
if (verificationResponse.status === 404 && !account.refreshToken)
return
@@ -220,38 +203,55 @@ async function revokeGitHubAuthorization(
})
}
async function revokeGoogleAuthorization(account: SocialAccount, fetchRequest: typeof fetch): Promise<void> {
const { token, tokenType } = revocationToken(account)
const result = await revokeGoogleToken(token, fetchRequest)
/**
* Creates the provider-aware authorization boundary used by account deletion.
*
* Every configured social provider must have an explicit revocation policy.
* Unknown providers abort deletion so a future login integration cannot
* silently regress to deleting only AIRI's local account records.
*
* @see https://developer.apple.com/documentation/signinwithapplerestapi/revoke-tokens
* @see https://developers.google.com/identity/protocols/oauth2/web-server#tokenrevoke
* @see https://docs.github.com/en/rest/apps/oauth-applications#delete-an-app-authorization
*/
export function createSocialAuthorizationRevoker(
db: AuthDatabase,
credentials: SocialAuthorizationCredentials,
fetchRequest: typeof fetch = fetch,
): SocialAuthorizationRevoker {
return {
async revokeForUser(userId) {
const accounts = await db
.select({
providerId: authSchema.account.providerId,
accessToken: authSchema.account.accessToken,
refreshToken: authSchema.account.refreshToken,
})
.from(authSchema.account)
.where(eq(authSchema.account.userId, userId))
// An inactive refresh token cannot revoke a still-live access token. Try the
// separately retained access token before accepting the authorization as
// gone; Google links a successful access-token revocation back to its grant.
if (result === 'inactive'
&& tokenType === 'refresh_token'
&& account.accessToken
&& account.accessToken !== token) {
await revokeGoogleToken(account.accessToken, fetchRequest)
for (const account of accounts) {
if (account.providerId === 'credential')
continue
if (account.providerId === 'apple') {
await revokeAppleAuthorization(account, credentials, fetchRequest)
continue
}
if (account.providerId === 'google') {
await revokeGoogleAuthorization(account, fetchRequest)
continue
}
if (account.providerId === 'github') {
await revokeGitHubAuthorization(account, credentials, fetchRequest)
continue
}
throw createServiceUnavailableError(
`Authorization revocation is not implemented for ${account.providerId}.`,
'oauth/revocation_not_supported',
{ providerId: account.providerId },
)
}
},
}
}
async function revokeGoogleToken(token: string, fetchRequest: typeof fetch): Promise<'inactive' | 'revoked'> {
const response = await fetchRequest('https://oauth2.googleapis.com/revoke', {
body: new URLSearchParams({ token }),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
method: 'POST',
})
// Google reports expired and previously revoked tokens as invalid_token.
// Either state means the saved credential can no longer authorize AIRI, so
// accepting it makes a partially completed deletion safe to retry.
if (response.ok)
return 'revoked'
if (await isInactiveGoogleToken(response))
return 'inactive'
throw createBadGatewayError('Google authorization revocation failed.', {
providerId: 'google',
statusCode: response.status,
})
}
+10 -10
View File
@@ -6,24 +6,24 @@ function createTestDeps() {
return {
auth: {
api: {
getSession: vi.fn(async () => null),
getOAuthServerConfig: vi.fn(async () => ({ issuer: 'https://api.airi.build/api/auth' })),
getOpenIdConfig: vi.fn(async () => ({ issuer: 'https://api.airi.build/api/auth' })),
getSession: vi.fn(async () => null),
},
handler: vi.fn(async () => new Response('auth-handler')),
} as any,
db: {
execute: vi.fn(async () => []),
} as any,
env: {
ADDITIONAL_TRUSTED_ORIGINS: [],
AUTH_UI_URL: 'https://accounts.airi.build/ui',
PUBLIC_URL: 'https://api.airi.build',
} as any,
rateLimitMetrics: null,
redis: {
ping: vi.fn(async () => 'PONG'),
} as any,
env: {
PUBLIC_URL: 'https://api.airi.build',
AUTH_UI_URL: 'https://accounts.airi.build/ui',
ADDITIONAL_TRUSTED_ORIGINS: [],
} as any,
rateLimitMetrics: null,
}
}
@@ -45,9 +45,9 @@ describe('standalone auth app', () => {
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
accounts: 'https://accounts.airi.build/ui',
issuer: 'https://api.airi.build/api/auth',
service: 'airi-auth',
issuer: 'https://api.airi.build/api/auth',
accounts: 'https://accounts.airi.build/ui',
})
})
@@ -67,8 +67,8 @@ describe('standalone auth app', () => {
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
checks: { db: 'ok', redis: 'ok' },
status: 'ready',
checks: { db: 'ok', redis: 'ok' },
})
expect(deps.db.execute).toHaveBeenCalledWith('SELECT 1 FROM "user" LIMIT 1')
expect(deps.redis.ping).toHaveBeenCalledTimes(1)
+79 -79
View File
@@ -21,9 +21,6 @@ function createMockDb(existingRowsByCall: unknown[][] = []) {
})
const db = {
insert: vi.fn(() => ({
values,
})),
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
@@ -31,30 +28,33 @@ function createMockDb(existingRowsByCall: unknown[][] = []) {
})),
})),
})),
insert: vi.fn(() => ({
values,
})),
}
return { capturedValues, db, limit, values }
return { db, limit, values, capturedValues }
}
describe('createAuth', () => {
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' })
const applePrivateKey = privateKey.export({ format: 'pem', type: 'pkcs8' }).toString()
const applePublicKey = publicKey.export({ format: 'pem', type: 'spki' }).toString()
const applePrivateKey = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
const applePublicKey = publicKey.export({ type: 'spki', format: 'pem' }).toString()
it('allows signed-in users to link OAuth accounts that use a different email', () => {
const auth = createAuth({} as unknown as AuthDatabase, {
ADDITIONAL_TRUSTED_ORIGINS: [],
AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: ['ai.moeru.airi-pocket', 'ai.moeru.airi-pro'],
AUTH_APPLE_CLIENT_ID: 'apple-service-id',
AUTH_APPLE_KEY_ID: 'apple-key-id',
AUTH_APPLE_PRIVATE_KEY_PEM: applePrivateKey,
AUTH_APPLE_TEAM_ID: 'apple-team-id',
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
PUBLIC_URL: 'http://localhost:3000',
AUTH_GOOGLE_CLIENT_ID: 'google-client',
AUTH_GOOGLE_CLIENT_SECRET: 'google-secret',
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
AUTH_APPLE_CLIENT_ID: 'apple-service-id',
AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: ['ai.moeru.airi-pocket', 'ai.moeru.airi-pro'],
AUTH_APPLE_TEAM_ID: 'apple-team-id',
AUTH_APPLE_KEY_ID: 'apple-key-id',
AUTH_APPLE_PRIVATE_KEY_PEM: applePrivateKey,
BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret',
PUBLIC_URL: 'http://localhost:3000',
ADDITIONAL_TRUSTED_ORIGINS: [],
} as unknown as AuthEnv)
expect(auth.options.account?.accountLinking?.allowDifferentEmails).toBe(true)
@@ -62,43 +62,43 @@ describe('createAuth', () => {
it('registers lastSeenAt as a server-managed Better Auth user field', () => {
const auth = createAuth({} as unknown as AuthDatabase, {
ADDITIONAL_TRUSTED_ORIGINS: [],
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
PUBLIC_URL: 'http://localhost:3000',
AUTH_GOOGLE_CLIENT_ID: 'google-client',
AUTH_GOOGLE_CLIENT_SECRET: 'google-secret',
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret',
PUBLIC_URL: 'http://localhost:3000',
ADDITIONAL_TRUSTED_ORIGINS: [],
} as unknown as AuthEnv)
expect(auth.options.user?.additionalFields?.lastSeenAt).toMatchObject({
input: false,
required: false,
returned: true,
type: 'date',
required: false,
input: false,
returned: true,
})
expect(getAuthTables(auth.options).user.fields.lastSeenAt).toMatchObject({
input: false,
required: false,
returned: true,
type: 'date',
required: false,
input: false,
returned: true,
})
})
it('asks social providers to show the account picker during OAuth authorization', () => {
const auth = createAuth({} as unknown as AuthDatabase, {
ADDITIONAL_TRUSTED_ORIGINS: [],
AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: ['ai.moeru.airi-pocket', 'ai.moeru.airi-pro'],
AUTH_APPLE_CLIENT_ID: 'apple-service-id',
AUTH_APPLE_KEY_ID: 'apple-key-id',
AUTH_APPLE_PRIVATE_KEY_PEM: applePrivateKey,
AUTH_APPLE_TEAM_ID: 'apple-team-id',
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
PUBLIC_URL: 'http://localhost:3000',
AUTH_GOOGLE_CLIENT_ID: 'google-client',
AUTH_GOOGLE_CLIENT_SECRET: 'google-secret',
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
AUTH_APPLE_CLIENT_ID: 'apple-service-id',
AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: ['ai.moeru.airi-pocket', 'ai.moeru.airi-pro'],
AUTH_APPLE_TEAM_ID: 'apple-team-id',
AUTH_APPLE_KEY_ID: 'apple-key-id',
AUTH_APPLE_PRIVATE_KEY_PEM: applePrivateKey,
BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret',
PUBLIC_URL: 'http://localhost:3000',
ADDITIONAL_TRUSTED_ORIGINS: [],
} as unknown as AuthEnv)
const google = auth.options.socialProviders?.google
@@ -111,18 +111,18 @@ describe('createAuth', () => {
it('does not register Apple when its optional credentials are absent', () => {
const auth = createAuth({} as unknown as AuthDatabase, {
ADDITIONAL_TRUSTED_ORIGINS: [],
AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: [],
AUTH_APPLE_CLIENT_ID: '',
AUTH_APPLE_KEY_ID: '',
AUTH_APPLE_PRIVATE_KEY_PEM: '',
AUTH_APPLE_TEAM_ID: '',
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
PUBLIC_URL: 'http://localhost:3000',
AUTH_GOOGLE_CLIENT_ID: 'google-client',
AUTH_GOOGLE_CLIENT_SECRET: 'google-secret',
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
AUTH_APPLE_CLIENT_ID: '',
AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: [],
AUTH_APPLE_TEAM_ID: '',
AUTH_APPLE_KEY_ID: '',
AUTH_APPLE_PRIVATE_KEY_PEM: '',
BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret',
PUBLIC_URL: 'http://localhost:3000',
ADDITIONAL_TRUSTED_ORIGINS: [],
} as unknown as AuthEnv)
expect(auth.options.socialProviders?.apple).toBeUndefined()
@@ -130,18 +130,18 @@ describe('createAuth', () => {
it('does not register Apple when its optional credentials are incomplete', () => {
const auth = createAuth({} as unknown as AuthDatabase, {
ADDITIONAL_TRUSTED_ORIGINS: [],
AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: ['ai.moeru.airi-pocket', 'ai.moeru.airi-pro'],
AUTH_APPLE_CLIENT_ID: 'apple-service-id',
AUTH_APPLE_KEY_ID: 'apple-key-id',
AUTH_APPLE_PRIVATE_KEY_PEM: '',
AUTH_APPLE_TEAM_ID: 'apple-team-id',
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
PUBLIC_URL: 'http://localhost:3000',
AUTH_GOOGLE_CLIENT_ID: 'google-client',
AUTH_GOOGLE_CLIENT_SECRET: 'google-secret',
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
AUTH_APPLE_CLIENT_ID: 'apple-service-id',
AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: ['ai.moeru.airi-pocket', 'ai.moeru.airi-pro'],
AUTH_APPLE_TEAM_ID: 'apple-team-id',
AUTH_APPLE_KEY_ID: 'apple-key-id',
AUTH_APPLE_PRIVATE_KEY_PEM: '',
BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret',
PUBLIC_URL: 'http://localhost:3000',
ADDITIONAL_TRUSTED_ORIGINS: [],
} as unknown as AuthEnv)
expect(auth.options.socialProviders?.apple).toBeUndefined()
@@ -149,18 +149,18 @@ describe('createAuth', () => {
it('configures Apple for web OAuth and native ID-token sign-in', async () => {
const auth = createAuth({} as unknown as AuthDatabase, {
ADDITIONAL_TRUSTED_ORIGINS: [],
AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: ['ai.moeru.airi-pocket', 'ai.moeru.airi-pro'],
AUTH_APPLE_CLIENT_ID: 'apple-service-id',
AUTH_APPLE_KEY_ID: 'apple-key-id',
AUTH_APPLE_PRIVATE_KEY_PEM: applePrivateKey,
AUTH_APPLE_TEAM_ID: 'apple-team-id',
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
PUBLIC_URL: 'http://localhost:3000',
AUTH_GOOGLE_CLIENT_ID: 'google-client',
AUTH_GOOGLE_CLIENT_SECRET: 'google-secret',
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
AUTH_APPLE_CLIENT_ID: 'apple-service-id',
AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: ['ai.moeru.airi-pocket', 'ai.moeru.airi-pro'],
AUTH_APPLE_TEAM_ID: 'apple-team-id',
AUTH_APPLE_KEY_ID: 'apple-key-id',
AUTH_APPLE_PRIVATE_KEY_PEM: applePrivateKey,
BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret',
PUBLIC_URL: 'http://localhost:3000',
ADDITIONAL_TRUSTED_ORIGINS: [],
} as unknown as AuthEnv)
const appleProvider = auth.options.socialProviders?.apple
@@ -177,9 +177,9 @@ describe('createAuth', () => {
await expect(jwtVerify(config.clientSecret, verificationKey, {
algorithms: ['ES256'],
audience: 'https://appleid.apple.com',
issuer: 'apple-team-id',
subject: 'apple-service-id',
audience: 'https://appleid.apple.com',
})).resolves.toBeDefined()
expect(config.clientId).toBe('apple-service-id')
expect(config.audience).toEqual([
@@ -190,24 +190,24 @@ describe('createAuth', () => {
expect(header).toMatchObject({ alg: 'ES256', kid: 'apple-key-id' })
expect(claims.exp! - claims.iat!).toBe(180 * 24 * 60 * 60)
expect(await config.mapProfileToUser?.({
sub: 'apple-user-id',
email: '',
email_verified: true,
is_private_email: false,
real_user_status: 2,
name: '',
picture: '',
real_user_status: 2,
sub: 'apple-user-id',
})).toEqual({
email: 'apple-user-id@apple.placeholder.local',
})
expect(await config.mapProfileToUser?.({
sub: 'apple-user-id',
email: 'relay@privaterelay.appleid.com',
email_verified: true,
is_private_email: true,
real_user_status: 2,
name: '',
picture: '',
real_user_status: 2,
sub: 'apple-user-id',
})).toEqual({
email: 'relay@privaterelay.appleid.com',
})
@@ -224,13 +224,13 @@ describe('createAuth', () => {
const auth = createAuth(
{} as unknown as AuthDatabase,
{
ADDITIONAL_TRUSTED_ORIGINS: [],
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
PUBLIC_URL: 'http://localhost:3000',
AUTH_GOOGLE_CLIENT_ID: 'google-client',
AUTH_GOOGLE_CLIENT_SECRET: 'google-secret',
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret',
PUBLIC_URL: 'http://localhost:3000',
ADDITIONAL_TRUSTED_ORIGINS: [],
} as unknown as AuthEnv,
undefined,
undefined,
@@ -252,12 +252,12 @@ describe('createAuth', () => {
throw new TypeError('Expected account-deletion hook')
await beforeDelete({
createdAt: new Date(),
id: 'user-1',
name: 'User One',
email: 'user@example.com',
emailVerified: true,
id: 'user-1',
image: null,
name: 'User One',
createdAt: new Date(),
updatedAt: new Date(),
}, new Request('http://localhost:3000/api/auth/delete-user'))
@@ -266,13 +266,13 @@ describe('createAuth', () => {
it('uses the Caddy public API origin as the Better Auth base URL', () => {
const auth = createAuth({} as unknown as AuthDatabase, {
ADDITIONAL_TRUSTED_ORIGINS: [],
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
PUBLIC_URL: 'https://api.airi.build',
AUTH_GOOGLE_CLIENT_ID: 'google-client',
AUTH_GOOGLE_CLIENT_SECRET: 'google-secret',
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret',
PUBLIC_URL: 'https://api.airi.build',
ADDITIONAL_TRUSTED_ORIGINS: [],
} as unknown as AuthEnv)
expect(auth.options.baseURL).toBe('https://api.airi.build')
@@ -281,7 +281,7 @@ describe('createAuth', () => {
describe('seedTrustedClients', () => {
it('seeds trusted first-party clients with explicit oauth metadata', async () => {
const { capturedValues, db, values } = createMockDb([[], [], []])
const { db, values, capturedValues } = createMockDb([[], [], []])
await seedTrustedClients(db as any, {
PUBLIC_URL: 'http://localhost:3000',
@@ -365,7 +365,7 @@ describe('seedTrustedClients', () => {
})
it('registers the Electron callback on the public API origin', async () => {
const { capturedValues, db } = createMockDb([[], [], []])
const { db, capturedValues } = createMockDb([[], [], []])
await seedTrustedClients(db as any, {
PUBLIC_URL: 'https://api.airi.build',
+25 -25
View File
@@ -4,14 +4,23 @@ import { describe, expect, it, vi } from 'vitest'
import { banGuard } from '../plugins/ban-guard'
type BanContext = Parameters<BanSessionCreateHook>[1]
type BanSession = Parameters<BanSessionCreateHook>[0]
type BanSessionCreateHook = NonNullable<SessionCreateHook>
type SessionCreateHook = NonNullable<NonNullable<NonNullable<BetterAuthOptions['databaseHooks']>['session']>['create']>['before']
type BanSessionCreateHook = NonNullable<SessionCreateHook>
type BanSession = Parameters<BanSessionCreateHook>[0]
type BanContext = Parameters<BanSessionCreateHook>[1]
function createContext(user: { banExpires: Date | null, banned: boolean }) {
async function getSessionCreateHook(): Promise<BanSessionCreateHook> {
const initialized = await banGuard().init?.({} as never)
const before = initialized?.options?.databaseHooks?.session?.create?.before
if (!before)
throw new TypeError('Expected the ban guard to register a session-create hook')
return before
}
function createContext(user: { banned: boolean, banExpires: Date | null }) {
const updateUser = vi.fn()
return {
updateUser,
context: {
context: {
internalAdapter: {
@@ -20,30 +29,21 @@ function createContext(user: { banExpires: Date | null, banned: boolean }) {
},
},
} as unknown as BanContext,
updateUser,
}
}
function createSession(userId: string): BanSession {
const now = new Date()
return {
createdAt: now,
expiresAt: new Date(now.getTime() + 60 * 60 * 1000),
id: 'session-1',
token: 'session-token',
updatedAt: now,
userId,
expiresAt: new Date(now.getTime() + 60 * 60 * 1000),
createdAt: now,
updatedAt: now,
}
}
async function getSessionCreateHook(): Promise<BanSessionCreateHook> {
const initialized = await banGuard().init?.({} as never)
const before = initialized?.options?.databaseHooks?.session?.create?.before
if (!before)
throw new TypeError('Expected the ban guard to register a session-create hook')
return before
}
describe('banGuard', () => {
// Review: https://github.com/moeru-ai/airi/pull/2303
// ROOT CAUSE:
@@ -57,11 +57,6 @@ describe('banGuard', () => {
expect(banGuard().schema).toMatchObject({
user: {
fields: {
banExpires: {
input: false,
required: false,
type: 'date',
},
banned: {
defaultValue: false,
input: false,
@@ -73,6 +68,11 @@ describe('banGuard', () => {
required: false,
type: 'string',
},
banExpires: {
input: false,
required: false,
type: 'date',
},
},
},
})
@@ -80,7 +80,7 @@ describe('banGuard', () => {
it('allows a session for an account that is not banned', async () => {
const before = await getSessionCreateHook()
const { context, updateUser } = createContext({ banExpires: null, banned: false })
const { context, updateUser } = createContext({ banned: false, banExpires: null })
await expect(before(
createSession('user-1'),
@@ -92,7 +92,7 @@ describe('banGuard', () => {
it('rejects a session for an account with a permanent ban', async () => {
const before = await getSessionCreateHook()
const { context, updateUser } = createContext({ banExpires: null, banned: true })
const { context, updateUser } = createContext({ banned: true, banExpires: null })
await expect(before(
createSession('user-1'),
@@ -117,8 +117,8 @@ describe('banGuard', () => {
it('allows an expired temporary ban without changing persisted ban state', async () => {
const before = await getSessionCreateHook()
const { context, updateUser } = createContext({
banExpires: new Date(Date.now() - 1000),
banned: true,
banExpires: new Date(Date.now() - 1000),
})
await expect(before(
@@ -132,8 +132,8 @@ describe('banGuard', () => {
it('rejects a session for an account with an active temporary ban', async () => {
const before = await getSessionCreateHook()
const { context, updateUser } = createContext({
banExpires: new Date(Date.now() + 60 * 1000),
banned: true,
banExpires: new Date(Date.now() + 60 * 1000),
})
await expect(before(
+9 -9
View File
@@ -4,15 +4,15 @@ import { parseAuthEnv } from '../env'
function baseAuthEnv(): Record<string, string> {
return {
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
DATABASE_URL: 'postgres://identity',
REDIS_URL: 'redis://identity',
PUBLIC_URL: 'https://api.airi.build',
RESOURCE_SERVER_URL: 'https://resource.internal',
BETTER_AUTH_SECRET: 'identity-secret-at-least-32-characters',
AUTH_GOOGLE_CLIENT_ID: 'google-client',
AUTH_GOOGLE_CLIENT_SECRET: 'google-secret',
BETTER_AUTH_SECRET: 'identity-secret-at-least-32-characters',
DATABASE_URL: 'postgres://identity',
PUBLIC_URL: 'https://api.airi.build',
REDIS_URL: 'redis://identity',
RESOURCE_SERVER_URL: 'https://resource.internal',
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
}
}
@@ -31,11 +31,11 @@ describe('parseAuthEnv', () => {
it('normalizes Apple audiences and escaped private-key newlines', () => {
const env = parseAuthEnv({
...baseAuthEnv(),
AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: 'ai.moeru.airi-pocket, ai.moeru.airi-pro, ai.moeru.airi-pocket',
AUTH_APPLE_CLIENT_ID: 'apple-service-id',
AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: 'ai.moeru.airi-pocket, ai.moeru.airi-pro, ai.moeru.airi-pocket',
AUTH_APPLE_TEAM_ID: 'apple-team-id',
AUTH_APPLE_KEY_ID: 'apple-key-id',
AUTH_APPLE_PRIVATE_KEY_PEM: 'line-one\\nline-two',
AUTH_APPLE_TEAM_ID: 'apple-team-id',
})
expect(env.AUTH_APPLE_APP_BUNDLE_IDENTIFIERS).toEqual([
+3 -3
View File
@@ -9,8 +9,8 @@ describe('auth origin policy', () => {
})
expect(getAuthTrustedOrigins({
ADDITIONAL_TRUSTED_ORIGINS: [],
PUBLIC_URL: 'https://api.airi.moeru.ai',
ADDITIONAL_TRUSTED_ORIGINS: [],
}, request)).toEqual([
'https://api.airi.moeru.ai',
'https://airi.moeru.ai',
@@ -27,8 +27,8 @@ describe('auth origin policy', () => {
it('includes explicit development origins without trusting native callback schemes', () => {
const origins = getAuthTrustedOrigins({
ADDITIONAL_TRUSTED_ORIGINS: ['https://10.0.0.129:5273'],
PUBLIC_URL: 'https://api.airi.build',
ADDITIONAL_TRUSTED_ORIGINS: ['https://10.0.0.129:5273'],
})
expect(origins).toContain('https://10.0.0.129:5273')
@@ -39,8 +39,8 @@ describe('auth origin policy', () => {
it('always trusts the first-party auth UI for email callbacks', () => {
expect(getAuthTrustedOrigins({
ADDITIONAL_TRUSTED_ORIGINS: [],
PUBLIC_URL: 'https://api.airi.build',
ADDITIONAL_TRUSTED_ORIGINS: [],
})).toContain('https://accounts.airi.build')
})
})
@@ -9,14 +9,14 @@ import { createAuthRoutes } from '../routes'
async function createApp(trustedProxy?: 'railway') {
const routes = await createAuthRoutes({
auth: {
api: { getSession: vi.fn(async () => null) },
handler: vi.fn(async () => new Response(null, { status: 200 })),
api: { getSession: vi.fn(async () => null) },
} as unknown as Parameters<typeof createAuthRoutes>[0]['auth'],
db: {} as unknown as Parameters<typeof createAuthRoutes>[0]['db'],
env: {
ADDITIONAL_TRUSTED_ORIGINS: [],
AUTH_UI_URL: 'https://accounts.airi.build/ui',
PUBLIC_URL: 'https://api.airi.build',
AUTH_UI_URL: 'https://accounts.airi.build/ui',
ADDITIONAL_TRUSTED_ORIGINS: [],
RATE_LIMIT_TRUSTED_PROXY: trustedProxy,
} as unknown as Parameters<typeof createAuthRoutes>[0]['env'],
rateLimitMetrics: null,
@@ -26,7 +26,7 @@ async function createApp(trustedProxy?: 'railway') {
}
async function listen(app: Hono<HonoEnv>, hostname = '127.0.0.1') {
const server = serve({ fetch: app.fetch, hostname, port: 0 })
const server = serve({ fetch: app.fetch, port: 0, hostname })
const port = await new Promise<number>((resolve) => {
server.once('listening', () => {
const address = server.address()
@@ -36,10 +36,10 @@ async function listen(app: Hono<HonoEnv>, hostname = '127.0.0.1') {
})
return {
origin: `http://${hostname.includes(':') ? `[${hostname}]` : hostname}:${port}`,
close: () => new Promise<void>((resolve, reject) => {
server.close(error => error ? reject(error) : resolve())
}),
origin: `http://${hostname.includes(':') ? `[${hostname}]` : hostname}:${port}`,
}
}
@@ -7,7 +7,7 @@ describe('resource API', () => {
const fetchRequest = vi.fn<typeof fetch>(async () => new Response(null, { status: 204 }))
const resourceApi = createResourceApi('https://resource.internal', fetchRequest)
await resourceApi.softDeleteUserData({ reason: 'user-requested', userId: 'user-1' })
await resourceApi.softDeleteUserData({ userId: 'user-1', reason: 'user-requested' })
expect(fetchRequest).toHaveBeenCalledTimes(1)
const [url, init] = fetchRequest.mock.calls[0]
@@ -24,7 +24,7 @@ describe('resource API', () => {
)
await expect(
resourceApi.softDeleteUserData({ reason: 'user-requested', userId: 'user-1' }),
resourceApi.softDeleteUserData({ userId: 'user-1', reason: 'user-requested' }),
).rejects.toMatchObject({ statusCode: 502 })
})
@@ -33,9 +33,9 @@ describe('resource API', () => {
const resourceApi = createResourceApi('https://resource.internal', fetchRequest)
await resourceApi.trackAuthEvent({
userId: 'user-1',
action: 'user_signed_up',
source: 'better-auth.user.create',
userId: 'user-1',
})
const [url, init] = fetchRequest.mock.calls[0]
@@ -11,21 +11,28 @@ import { createAuthRoutes } from '../routes'
// flag lives on the user row (better-auth admin plugin), so we drive it via the
// mocked session — no DB query happens on this path.
interface SessionUser { banExpires: Date | null, banned: boolean, email: string, id: string }
interface SessionUser { id: string, email: string, banned: boolean, banExpires: Date | null }
function sessionFor(user: SessionUser) {
return {
user: { ...user, name: 'U', emailVerified: true, image: null, createdAt: new Date(), updatedAt: new Date() },
session: { id: 's1', userId: user.id, token: 't', createdAt: new Date(), updatedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), ipAddress: null, userAgent: null },
}
}
async function buildRoutes(currentUser: SessionUser) {
const handler = vi.fn(async () => new Response(JSON.stringify({ sub: currentUser.id }), { headers: { 'content-type': 'application/json' }, status: 200 }))
const handler = vi.fn(async () => new Response(JSON.stringify({ sub: currentUser.id }), { status: 200, headers: { 'content-type': 'application/json' } }))
const deps: AuthRoutesDeps = {
auth: {
api: { getSession: vi.fn(async () => sessionFor(currentUser)) },
handler,
api: { getSession: vi.fn(async () => sessionFor(currentUser)) },
} as any,
db: {} as any, // userinfo path never queries the DB
env: {
ADDITIONAL_TRUSTED_ORIGINS: [],
AUTH_UI_URL: 'https://accounts.airi.build/ui',
PUBLIC_URL: 'http://localhost:3000',
AUTH_UI_URL: 'https://accounts.airi.build/ui',
ADDITIONAL_TRUSTED_ORIGINS: [],
} as any,
rateLimitMetrics: null,
}
@@ -39,21 +46,14 @@ async function buildRoutes(currentUser: SessionUser) {
return c.json({ error: 'internal', message: (err as Error).message }, 500)
})
return { handler, routes: app }
}
function sessionFor(user: SessionUser) {
return {
session: { createdAt: new Date(), expiresAt: new Date(Date.now() + 60_000), id: 's1', ipAddress: null, token: 't', updatedAt: new Date(), userAgent: null, userId: user.id },
user: { ...user, createdAt: new Date(), emailVerified: true, image: null, name: 'U', updatedAt: new Date() },
}
return { routes: app, handler }
}
describe('oidc /oauth2/userinfo ban guard', () => {
beforeEach(() => vi.clearAllMocks())
it('returns 403 for a banned subject before reaching the better-auth handler', async () => {
const { handler, routes } = await buildRoutes({ banExpires: null, banned: true, email: 'banme@example.com', id: 'uid_ban' })
const { routes, handler } = await buildRoutes({ id: 'uid_ban', email: 'banme@example.com', banned: true, banExpires: null })
const res = await routes.request('/api/auth/oauth2/userinfo', { headers: { Authorization: 'Bearer banned-jwt' } })
@@ -62,7 +62,7 @@ describe('oidc /oauth2/userinfo ban guard', () => {
})
it('passes a non-banned subject through to the better-auth handler', async () => {
const { handler, routes } = await buildRoutes({ banExpires: null, banned: false, email: 'ok@example.com', id: 'uid_ok' })
const { routes, handler } = await buildRoutes({ id: 'uid_ok', email: 'ok@example.com', banned: false, banExpires: null })
const res = await routes.request('/api/auth/oauth2/userinfo', { headers: { Authorization: 'Bearer ok-jwt' } })
@@ -72,7 +72,7 @@ describe('oidc /oauth2/userinfo ban guard', () => {
})
it('passes a subject whose ban has expired', async () => {
const { handler, routes } = await buildRoutes({ banExpires: new Date(Date.now() - 1000), banned: true, email: 'exp@example.com', id: 'uid_exp' })
const { routes, handler } = await buildRoutes({ id: 'uid_exp', email: 'exp@example.com', banned: true, banExpires: new Date(Date.now() - 1000) })
const res = await routes.request('/api/auth/oauth2/userinfo', { headers: { Authorization: 'Bearer exp-jwt' } })
@@ -83,10 +83,10 @@ describe('oidc /oauth2/userinfo ban guard', () => {
describe('auth UI routes', () => {
it('keeps the Google provider hint on the OIDC sign-in redirect', async () => {
const { handler, routes } = await buildRoutes({ banExpires: null, banned: false, email: 'ok@example.com', id: 'uid_ok' })
const { routes, handler } = await buildRoutes({ id: 'uid_ok', email: 'ok@example.com', banned: false, banExpires: null })
handler.mockResolvedValueOnce(new Response(null, {
headers: { location: '/auth/sign-in?client_id=airi-stage-pocket&response_type=code' },
status: 302,
headers: { location: '/auth/sign-in?client_id=airi-stage-pocket&response_type=code' },
}))
const res = await routes.request('/api/auth/oauth2/authorize?client_id=airi-stage-pocket&response_type=code&provider=google')
@@ -102,10 +102,10 @@ describe('auth UI routes', () => {
})
it('keeps the GitHub provider hint on the OIDC sign-in redirect', async () => {
const { handler, routes } = await buildRoutes({ banExpires: null, banned: false, email: 'ok@example.com', id: 'uid_ok' })
const { routes, handler } = await buildRoutes({ id: 'uid_ok', email: 'ok@example.com', banned: false, banExpires: null })
handler.mockResolvedValueOnce(new Response(null, {
headers: { location: '/auth/sign-in?client_id=airi-stage-pocket&response_type=code' },
status: 302,
headers: { location: '/auth/sign-in?client_id=airi-stage-pocket&response_type=code' },
}))
const res = await routes.request('/api/auth/oauth2/authorize?client_id=airi-stage-pocket&response_type=code&provider=github')
@@ -114,10 +114,10 @@ describe('auth UI routes', () => {
})
it('keeps the Steam provider hint on the OIDC sign-in redirect', async () => {
const { handler, routes } = await buildRoutes({ banExpires: null, banned: false, email: 'ok@example.com', id: 'uid_ok' })
const { routes, handler } = await buildRoutes({ id: 'uid_ok', email: 'ok@example.com', banned: false, banExpires: null })
handler.mockResolvedValueOnce(new Response(null, {
headers: { location: '/auth/sign-in?client_id=airi-stage-pocket&response_type=code' },
status: 302,
headers: { location: '/auth/sign-in?client_id=airi-stage-pocket&response_type=code' },
}))
const res = await routes.request('/api/auth/oauth2/authorize?client_id=airi-stage-pocket&response_type=code&provider=steam')
@@ -126,10 +126,10 @@ describe('auth UI routes', () => {
})
it('does not forward an unknown provider to the auth UI', async () => {
const { handler, routes } = await buildRoutes({ banExpires: null, banned: false, email: 'ok@example.com', id: 'uid_ok' })
const { routes, handler } = await buildRoutes({ id: 'uid_ok', email: 'ok@example.com', banned: false, banExpires: null })
handler.mockResolvedValueOnce(new Response(null, {
headers: { location: '/auth/sign-in?client_id=airi-stage-pocket&response_type=code' },
status: 302,
headers: { location: '/auth/sign-in?client_id=airi-stage-pocket&response_type=code' },
}))
const res = await routes.request('/api/auth/oauth2/authorize?client_id=airi-stage-pocket&response_type=code&provider=unknown')
@@ -138,10 +138,10 @@ describe('auth UI routes', () => {
})
it('keeps the generic sign-in redirect when the request has no provider', async () => {
const { handler, routes } = await buildRoutes({ banExpires: null, banned: false, email: 'ok@example.com', id: 'uid_ok' })
const { routes, handler } = await buildRoutes({ id: 'uid_ok', email: 'ok@example.com', banned: false, banExpires: null })
handler.mockResolvedValueOnce(new Response(null, {
headers: { location: '/auth/sign-in?client_id=airi-stage-pocket&response_type=code' },
status: 302,
headers: { location: '/auth/sign-in?client_id=airi-stage-pocket&response_type=code' },
}))
const res = await routes.request('/api/auth/oauth2/authorize?client_id=airi-stage-pocket&response_type=code')
@@ -150,7 +150,7 @@ describe('auth UI routes', () => {
})
it('redirects sign-in provider shortcut to the standalone auth UI', async () => {
const { routes } = await buildRoutes({ banExpires: null, banned: false, email: 'ok@example.com', id: 'uid_ok' })
const { routes } = await buildRoutes({ id: 'uid_ok', email: 'ok@example.com', banned: false, banExpires: null })
const res = await routes.request('/auth/sign-in?provider=github&client_id=stage-web&prompt=login&redirect_uri=http%3A%2F%2Flocalhost%3A5173%2Fauth%2Fcallback')
@@ -161,7 +161,7 @@ describe('auth UI routes', () => {
})
it('redirects Electron OIDC callback queries to the standalone auth UI relay', async () => {
const { routes } = await buildRoutes({ banExpires: null, banned: false, email: 'ok@example.com', id: 'uid_ok' })
const { routes } = await buildRoutes({ id: 'uid_ok', email: 'ok@example.com', banned: false, banExpires: null })
const res = await routes.request('/api/auth/oidc/electron-callback?code=sample-code&state=43123%3Aopaque-state')
@@ -10,9 +10,24 @@ import { describe, expect, it, vi } from 'vitest'
import { createSocialAuthorizationRevoker } from '../social-authorization'
interface SocialAccount {
accessToken: null | string
providerId: string
refreshToken: null | string
accessToken: string | null
refreshToken: string | null
}
function createCredentials(): Pick<AuthEnv, 'AUTH_GOOGLE_CLIENT_ID' | 'AUTH_GOOGLE_CLIENT_SECRET' | 'AUTH_GITHUB_CLIENT_ID' | 'AUTH_GITHUB_CLIENT_SECRET' | 'AUTH_APPLE_CLIENT_ID' | 'AUTH_APPLE_TEAM_ID' | 'AUTH_APPLE_KEY_ID' | 'AUTH_APPLE_PRIVATE_KEY_PEM'> {
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' })
return {
AUTH_GOOGLE_CLIENT_ID: 'google-client-id',
AUTH_GOOGLE_CLIENT_SECRET: 'google-client-secret',
AUTH_GITHUB_CLIENT_ID: 'github-client-id',
AUTH_GITHUB_CLIENT_SECRET: 'github-client-secret',
AUTH_APPLE_CLIENT_ID: 'apple-service-id',
AUTH_APPLE_TEAM_ID: 'apple-team-id',
AUTH_APPLE_KEY_ID: 'apple-key-id',
AUTH_APPLE_PRIVATE_KEY_PEM: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
}
}
function createAccountDb(accounts: SocialAccount[]): AuthDatabase {
@@ -25,26 +40,11 @@ function createAccountDb(accounts: SocialAccount[]): AuthDatabase {
} as unknown as AuthDatabase
}
function createCredentials(): Pick<AuthEnv, 'AUTH_APPLE_CLIENT_ID' | 'AUTH_APPLE_KEY_ID' | 'AUTH_APPLE_PRIVATE_KEY_PEM' | 'AUTH_APPLE_TEAM_ID' | 'AUTH_GITHUB_CLIENT_ID' | 'AUTH_GITHUB_CLIENT_SECRET' | 'AUTH_GOOGLE_CLIENT_ID' | 'AUTH_GOOGLE_CLIENT_SECRET'> {
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' })
return {
AUTH_APPLE_CLIENT_ID: 'apple-service-id',
AUTH_APPLE_KEY_ID: 'apple-key-id',
AUTH_APPLE_PRIVATE_KEY_PEM: privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(),
AUTH_APPLE_TEAM_ID: 'apple-team-id',
AUTH_GITHUB_CLIENT_ID: 'github-client-id',
AUTH_GITHUB_CLIENT_SECRET: 'github-client-secret',
AUTH_GOOGLE_CLIENT_ID: 'google-client-id',
AUTH_GOOGLE_CLIENT_SECRET: 'google-client-secret',
}
}
describe('social authorization revocation', () => {
it('revokes the saved Apple refresh token', async () => {
const fetchRequest = vi.fn<typeof fetch>(async () => new Response(null, { status: 200 }))
const revoker = createSocialAuthorizationRevoker(
createAccountDb([{ accessToken: 'apple-access-token', providerId: 'apple', refreshToken: 'apple-refresh-token' }]),
createAccountDb([{ providerId: 'apple', accessToken: 'apple-access-token', refreshToken: 'apple-refresh-token' }]),
createCredentials(),
fetchRequest,
)
@@ -74,7 +74,7 @@ describe('social authorization revocation', () => {
it('uses the Apple access token when no refresh token was retained', async () => {
const fetchRequest = vi.fn<typeof fetch>(async () => new Response(null, { status: 200 }))
const revoker = createSocialAuthorizationRevoker(
createAccountDb([{ accessToken: 'apple-access-token', providerId: 'apple', refreshToken: null }]),
createAccountDb([{ providerId: 'apple', accessToken: 'apple-access-token', refreshToken: null }]),
createCredentials(),
fetchRequest,
)
@@ -89,22 +89,22 @@ describe('social authorization revocation', () => {
it('aborts deletion when Apple rejects revocation', async () => {
const revoker = createSocialAuthorizationRevoker(
createAccountDb([{ accessToken: null, providerId: 'apple', refreshToken: 'apple-refresh-token' }]),
createAccountDb([{ providerId: 'apple', accessToken: null, refreshToken: 'apple-refresh-token' }]),
createCredentials(),
vi.fn<typeof fetch>(async () => Response.json({ error: 'invalid_client' }, { status: 400 })),
)
await expect(revoker.revokeForUser('user-1')).rejects.toMatchObject({
details: { providerId: 'apple', statusCode: 400 },
errorCode: 'BAD_GATEWAY',
statusCode: 502,
errorCode: 'BAD_GATEWAY',
details: { providerId: 'apple', statusCode: 400 },
})
})
it('revokes Google with the refresh token when available', async () => {
const fetchRequest = vi.fn<typeof fetch>(async () => new Response(null, { status: 200 }))
const revoker = createSocialAuthorizationRevoker(
createAccountDb([{ accessToken: 'google-access-token', providerId: 'google', refreshToken: 'google-refresh-token' }]),
createAccountDb([{ providerId: 'google', accessToken: 'google-access-token', refreshToken: 'google-refresh-token' }]),
createCredentials(),
fetchRequest,
)
@@ -121,7 +121,7 @@ describe('social authorization revocation', () => {
it('treats an already invalid Google token as revoked on retry', async () => {
const revoker = createSocialAuthorizationRevoker(
createAccountDb([{ accessToken: 'google-access-token', providerId: 'google', refreshToken: null }]),
createAccountDb([{ providerId: 'google', accessToken: 'google-access-token', refreshToken: null }]),
createCredentials(),
vi.fn<typeof fetch>(async () => Response.json({ error: 'invalid_token' }, { status: 400 })),
)
@@ -134,7 +134,7 @@ describe('social authorization revocation', () => {
.mockResolvedValueOnce(Response.json({ error: 'invalid_token' }, { status: 400 }))
.mockResolvedValueOnce(new Response(null, { status: 200 }))
const revoker = createSocialAuthorizationRevoker(
createAccountDb([{ accessToken: 'google-access-token', providerId: 'google', refreshToken: 'google-refresh-token' }]),
createAccountDb([{ providerId: 'google', accessToken: 'google-access-token', refreshToken: 'google-refresh-token' }]),
createCredentials(),
fetchRequest,
)
@@ -149,7 +149,7 @@ describe('social authorization revocation', () => {
it('deletes the GitHub application grant with app authentication', async () => {
const fetchRequest = vi.fn<typeof fetch>(async () => new Response(null, { status: 204 }))
const revoker = createSocialAuthorizationRevoker(
createAccountDb([{ accessToken: 'github-access-token', providerId: 'github', refreshToken: null }]),
createAccountDb([{ providerId: 'github', accessToken: 'github-access-token', refreshToken: null }]),
createCredentials(),
fetchRequest,
)
@@ -174,7 +174,7 @@ describe('social authorization revocation', () => {
.mockResolvedValueOnce(new Response(null, { status: 422 }))
.mockResolvedValueOnce(new Response(null, { status: 404 }))
const revoker = createSocialAuthorizationRevoker(
createAccountDb([{ accessToken: 'github-access-token', providerId: 'github', refreshToken: null }]),
createAccountDb([{ providerId: 'github', accessToken: 'github-access-token', refreshToken: null }]),
createCredentials(),
fetchRequest,
)
@@ -192,15 +192,15 @@ describe('social authorization revocation', () => {
.mockResolvedValueOnce(new Response(null, { status: 503 }))
.mockResolvedValueOnce(Response.json({ id: 1 }, { status: 200 }))
const revoker = createSocialAuthorizationRevoker(
createAccountDb([{ accessToken: 'github-access-token', providerId: 'github', refreshToken: null }]),
createAccountDb([{ providerId: 'github', accessToken: 'github-access-token', refreshToken: null }]),
createCredentials(),
fetchRequest,
)
await expect(revoker.revokeForUser('user-1')).rejects.toMatchObject({
details: { providerId: 'github', statusCode: 503, verificationStatusCode: 200 },
errorCode: 'BAD_GATEWAY',
statusCode: 502,
errorCode: 'BAD_GATEWAY',
details: { providerId: 'github', statusCode: 503, verificationStatusCode: 200 },
})
})
@@ -209,21 +209,21 @@ describe('social authorization revocation', () => {
.mockResolvedValueOnce(new Response(null, { status: 422 }))
.mockResolvedValueOnce(new Response(null, { status: 404 }))
const revoker = createSocialAuthorizationRevoker(
createAccountDb([{ accessToken: 'github-access-token', providerId: 'github', refreshToken: 'github-refresh-token' }]),
createAccountDb([{ providerId: 'github', accessToken: 'github-access-token', refreshToken: 'github-refresh-token' }]),
createCredentials(),
fetchRequest,
)
await expect(revoker.revokeForUser('user-1')).rejects.toMatchObject({
details: { providerId: 'github', statusCode: 422, verificationStatusCode: 404 },
statusCode: 502,
details: { providerId: 'github', statusCode: 422, verificationStatusCode: 404 },
})
})
it('ignores credential accounts because they have no external grant', async () => {
const fetchRequest = vi.fn<typeof fetch>()
const revoker = createSocialAuthorizationRevoker(
createAccountDb([{ accessToken: null, providerId: 'credential', refreshToken: null }]),
createAccountDb([{ providerId: 'credential', accessToken: null, refreshToken: null }]),
createCredentials(),
fetchRequest,
)
@@ -235,29 +235,29 @@ describe('social authorization revocation', () => {
it('aborts deletion when a social account has no revocable token', async () => {
const revoker = createSocialAuthorizationRevoker(
createAccountDb([{ accessToken: null, providerId: 'google', refreshToken: null }]),
createAccountDb([{ providerId: 'google', accessToken: null, refreshToken: null }]),
createCredentials(),
vi.fn<typeof fetch>(),
)
await expect(revoker.revokeForUser('user-1')).rejects.toMatchObject({
details: { providerId: 'google' },
errorCode: 'oauth/revocation_token_missing',
statusCode: 503,
errorCode: 'oauth/revocation_token_missing',
details: { providerId: 'google' },
})
})
it('aborts deletion for an external provider without a revocation policy', async () => {
const revoker = createSocialAuthorizationRevoker(
createAccountDb([{ accessToken: 'token', providerId: 'future-provider', refreshToken: null }]),
createAccountDb([{ providerId: 'future-provider', accessToken: 'token', refreshToken: null }]),
createCredentials(),
vi.fn<typeof fetch>(),
)
await expect(revoker.revokeForUser('user-1')).rejects.toMatchObject({
details: { providerId: 'future-provider' },
errorCode: 'oauth/revocation_not_supported',
statusCode: 503,
errorCode: 'oauth/revocation_not_supported',
details: { providerId: 'future-provider' },
})
})
})
+38 -38
View File
@@ -18,34 +18,6 @@ vi.mock('ofetch', () => ({
/** Test fixture: arbitrary valid-format SteamID64 used in fake OpenID callbacks. */
const STEAM_ID = '76561198012345678'
/** Builds a fake Steam OpenID `id_res` callback query, as if Steam redirected the browser here. */
function buildCallbackQuery(state: string, steamId = STEAM_ID): string {
const params = new URLSearchParams({
'openid.assoc_handle': 'test-handle',
'openid.claimed_id': `https://steamcommunity.com/openid/id/${steamId}`,
'openid.identity': `https://steamcommunity.com/openid/id/${steamId}`,
'openid.mode': 'id_res',
'openid.ns': 'http://specs.openid.net/auth/2.0',
'openid.op_endpoint': 'https://steamcommunity.com/openid/login',
'openid.response_nonce': '2026-07-31T00:00:00Zxxxxx',
'openid.return_to': 'http://localhost/api/auth/steam/callback',
'openid.sig': 'test-signature',
'openid.signed': 'signed,op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle',
state,
})
return params.toString()
}
async function createTestAuth() {
const db = await createTestDatabase()
return betterAuth({
baseURL: 'http://localhost',
database: drizzleAdapter(db, { provider: 'pg', schema }),
plugins: [steam()],
secret: 'test-secret',
})
}
/**
* Merges `Set-Cookie` headers from one or more responses into a single
* `Cookie` header value, later sources overriding earlier ones by name.
@@ -75,6 +47,34 @@ function forwardableCookieHeader(...headerSources: Headers[]): string {
return Array.from(cookies.entries()).map(([name, value]) => `${name}=${value}`).join('; ')
}
/** Builds a fake Steam OpenID `id_res` callback query, as if Steam redirected the browser here. */
function buildCallbackQuery(state: string, steamId = STEAM_ID): string {
const params = new URLSearchParams({
state,
'openid.mode': 'id_res',
'openid.ns': 'http://specs.openid.net/auth/2.0',
'openid.op_endpoint': 'https://steamcommunity.com/openid/login',
'openid.claimed_id': `https://steamcommunity.com/openid/id/${steamId}`,
'openid.identity': `https://steamcommunity.com/openid/id/${steamId}`,
'openid.return_to': 'http://localhost/api/auth/steam/callback',
'openid.response_nonce': '2026-07-31T00:00:00Zxxxxx',
'openid.assoc_handle': 'test-handle',
'openid.signed': 'signed,op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle',
'openid.sig': 'test-signature',
})
return params.toString()
}
async function createTestAuth() {
const db = await createTestDatabase()
return betterAuth({
database: drizzleAdapter(db, { provider: 'pg', schema }),
secret: 'test-secret',
baseURL: 'http://localhost',
plugins: [steam()],
})
}
describe('steam auth plugin', () => {
let auth: Awaited<ReturnType<typeof createTestAuth>>
@@ -119,7 +119,7 @@ describe('steam auth plugin', () => {
mockSteamVerification(true)
const context = await auth.$context
const { headers: startHeaders, response: startResponse } = await auth.api.signInSteam({
const { response: startResponse, headers: startHeaders } = await auth.api.signInSteam({
body: { callbackURL: 'http://localhost/ui/profile' },
returnHeaders: true,
})
@@ -141,7 +141,7 @@ describe('steam auth plugin', () => {
expect(user?.email).toBe(`${STEAM_ID}@steam.placeholder.local`)
expect(user?.emailVerified).toBe(true)
const { headers: secondStartHeaders, response: secondStart } = await auth.api.signInSteam({
const { response: secondStart, headers: secondStartHeaders } = await auth.api.signInSteam({
body: { callbackURL: 'http://localhost/ui/profile' },
returnHeaders: true,
})
@@ -158,7 +158,7 @@ describe('steam auth plugin', () => {
it('redirects to an error URL when Steam verification fails', async () => {
mockSteamVerification(false)
const { headers: startHeaders, response: startResponse } = await auth.api.signInSteam({
const { response: startResponse, headers: startHeaders } = await auth.api.signInSteam({
body: { callbackURL: 'http://localhost/ui/profile' },
returnHeaders: true,
})
@@ -178,7 +178,7 @@ describe('steam auth plugin', () => {
it('sets a 10-second timeout for Steam callback verification', async () => {
mockSteamVerification(true)
const { headers: startHeaders, response: startResponse } = await auth.api.signInSteam({
const { response: startResponse, headers: startHeaders } = await auth.api.signInSteam({
body: { callbackURL: 'http://localhost/ui/profile' },
returnHeaders: true,
})
@@ -206,7 +206,7 @@ describe('steam auth plugin', () => {
// Sign in as a fresh user via Steam first, to get a session cookie to link against.
const primarySteamId = '76561198011111111'
const { headers: primaryStartHeaders, response: primaryStart } = await auth.api.signInSteam({
const { response: primaryStart, headers: primaryStartHeaders } = await auth.api.signInSteam({
body: { callbackURL: 'http://localhost/ui/profile' },
returnHeaders: true,
})
@@ -220,7 +220,7 @@ describe('steam auth plugin', () => {
// Now link a second Steam account to that same session.
const secondSteamId = '76561198022222222'
const { headers: linkStartHeaders, response: linkStart } = await auth.api.linkSteam({
const { response: linkStart, headers: linkStartHeaders } = await auth.api.linkSteam({
body: { callbackURL: 'http://localhost/ui/profile' },
headers: { cookie: sessionCookie },
returnHeaders: true,
@@ -249,13 +249,13 @@ describe('steam auth plugin', () => {
name: 'Someone Else',
})).id
await context.internalAdapter.linkAccount({
accountId: claimedSteamId,
providerId: 'steam',
userId: claimingUserId,
providerId: 'steam',
accountId: claimedSteamId,
})
// A second, unrelated user tries to link the same Steam account.
const { headers: primaryStartHeaders, response: primaryStart } = await auth.api.signInSteam({
const { response: primaryStart, headers: primaryStartHeaders } = await auth.api.signInSteam({
body: { callbackURL: 'http://localhost/ui/profile' },
returnHeaders: true,
})
@@ -267,7 +267,7 @@ describe('steam auth plugin', () => {
))
const sessionCookie = forwardableCookieHeader(primaryCallback.headers)
const { headers: linkStartHeaders, response: linkStart } = await auth.api.linkSteam({
const { response: linkStart, headers: linkStartHeaders } = await auth.api.linkSteam({
body: { callbackURL: 'http://localhost/ui/profile' },
headers: { cookie: sessionCookie },
returnHeaders: true,