Ctrl K
ComunidadGuías…

server/auth.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { agentAuth } from '@better-auth/agent-auth'
import { i18n } from '@better-auth/i18n'
import { oauthDeviceAuthorization, oauthProvider } from '@better-auth/oauth-provider'
import { passkey } from '@better-auth/passkey'
import type { TransactionalEmailSender } from '@server/adapters/gateways/email/sender'
import { verifyStoredPassword } from '@server/adapters/gateways/migrated-password'
import { createDrizzleAgentTokenRepository } from '@server/adapters/repos/agent-tokens'
import { createDrizzleAgentRepository } from '@server/adapters/repos/agents'
import { createDrizzleApplicationRepository } from '@server/adapters/repos/applications'
import { createDrizzleAuthorizationRepository } from '@server/adapters/repos/authorization'
import { createDrizzleConfigzRepository } from '@server/adapters/repos/configz'
import { createExternalResourceRepository } from '@server/adapters/repos/external-resources'
import type { AuthConnectorConfig } from '@server/usecases/connectors'
import type { Deps } from '@server/usecases/deps'
import { mayCreateOrganization } from '@server/usecases/developer-access'
import type { IdentifierGenerator } from '@server/usecases/identifier-generator'
import { organizationUserHasScope } from '@server/usecases/organization-membership-scopes'
import type { ApplicationRepository } from '@server/usecases/ports'
import { findPlatformOrganization } from '@server/usecases/system-resources'
import { APIError, betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { genericOAuth, jwt, oneTap, phoneNumber, siwe, twoFactor } from 'better-auth/plugins'
import { emailOTP } from 'better-auth/plugins/email-otp'
import { organization } from 'better-auth/plugins/organization'
import { username } from 'better-auth/plugins/username'
import { and, eq, isNull, ne } from 'drizzle-orm'
import { verifyMessage } from 'viem'
import { parseSiweMessage, validateSiweMessage } from 'viem/siwe'
import { deviceCodeGrantType, userConfigurableApplicationScopes } from '../shared/api/applications'
import type { ManagementSignInSettingsResponse } from '../shared/api/management'
import type { SecurityPolicy } from '../shared/api/security'
import { bindEmailVerification, hasLiveEmailVerification } from './auth-email-verification'
import {
  applicationUserHasAccess,
  buildOAuthAccessTokenClaims,
  buildOAuthIdTokenClaims,
  createNonce,
  filterOAuthAccessTokenScopes,
  sendPasswordChangedNotification,
  sendSmsOtp,
  siweDomain,
} from './auth-helpers'
import { betterAuthTranslations } from './auth-i18n'
import type { Database } from './db/client'
import * as schema from './db/schema'
import { hashPassword } from './domain/password'
import { resolveOAuthAuthorizationContexts } from './usecases/applications'

export {
  buildOAuthAccessTokenClaims,
  filterOAuthAccessTokenScopes,
} from './auth-helpers'

import { createUserRepository } from '@server/adapters/repos/users'
import type { WebhookEvent } from '@shared/api/webhooks'
import { organizationAccessControl, organizationRoles } from '@shared/organization-access'

const oauthScopes = ['openid', 'profile', 'email', 'groups', 'offline_access']
const teamNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/

export function createAuth(
  db: Database,
  ids: IdentifierGenerator,
  secret: string,
  baseURL: string,
  trustedOrigins: string[],
  emailSender: TransactionalEmailSender,
  securityPolicy: SecurityPolicy,
  connectors: AuthConnectorConfig = {
    trustedProviders: [],
    socialProviders: {},
    genericOAuthProviders: [],
    cacheKey: '[]',
  },
  options: {
    builtInProviders?: ManagementSignInSettingsResponse['builtInProviders']
    emailDeliveryReady?: boolean
    twoFactorEmailOtpEnabled?: boolean
    validAudiences?: string[]
    externalHttp?: Deps['externalHttp']
    publishWebhookEvent?: (event: WebhookEvent, data: Record<string, unknown>) => Promise<void>
  } = {},
) {
  const applications = createDrizzleApplicationRepository(db, ids)
  const configz = createDrizzleConfigzRepository(db)
  const externalResources = createExternalResourceRepository(db)
  // The better-auth boundary builds its own repos; only the slices the token-claim
  // and agent-capability usecases read are populated here.
  const deps = {
    ids,
    authorization: createDrizzleAuthorizationRepository(db, ids),
    applications,
    users: createUserRepository(db, ids),
    agents: createDrizzleAgentRepository(db),
    agentTokens: createDrizzleAgentTokenRepository(db),
    externalHttp: options.externalHttp,
  } as unknown as Deps

  const baseAdapter = drizzleAdapter(db, { provider: 'sqlite', schema })
  const activeUserAdapter: typeof baseAdapter = (options) => {
    const adapter = baseAdapter(options)
    return {
      ...adapter,
      findOne: (input) =>
        adapter.findOne(
          input.model === 'user'
            ? { ...input, where: [...(input.where ?? []), { field: 'deletedAt', value: null }] }
            : input,
        ),
      findMany: (input) =>
        adapter.findMany(
          input.model === 'user'
            ? { ...input, where: [...(input.where ?? []), { field: 'deletedAt', value: null }] }
            : input,
        ),
    }
  }
  const auth = betterAuth({
    appName: 'Realmroot',
    onAPIError: { errorURL: '/auth/error' },
    database: activeUserAdapter,
    advanced: {
      database: {
        generateId: () => ids.generate(),
        joins: true,
      },
    },
    secret,
    baseURL,
    disabledPaths: [
      '/token',
      ...(!securityPolicy.passkeys.enabled
        ? [
            '/passkey/generate-register-options',
            '/passkey/generate-authenticate-options',
            '/passkey/verify-registration',
            '/passkey/verify-authentication',
            '/passkey/list-user-passkeys',
            '/passkey/delete-passkey',
            '/passkey/update-passkey',
          ]
        : []),
    ],
    databaseHooks: {
      user: options.publishWebhookEvent
        ? {
            create: {
              after: async (user) =>
                options.publishWebhookEvent?.('user.created', {
                  user: webhookRecord(user, [
                    'id',
                    'email',
                    'emailVerified',
                    'name',
                    'username',
                    'role',
                    'createdAt',
                    'updatedAt',
                  ]),
                }),
            },
            update: {
              after: async (user) =>
                options.publishWebhookEvent?.('user.updated', {
                  user: webhookRecord(user, [
                    'id',
                    'email',
                    'emailVerified',
                    'name',
                    'username',
                    'role',
                    'createdAt',
                    'updatedAt',
                  ]),
                }),
            },
            delete: {
              after: async (user) =>
                options.publishWebhookEvent?.('user.deleted', {
                  user: webhookRecord(user, ['id', 'email', 'name', 'username', 'role', 'createdAt', 'updatedAt']),
                }),
            },
          }
        : undefined,
      session: options.publishWebhookEvent
        ? {
            create: {
              after: async (session) =>
                options.publishWebhookEvent?.('session.created', {
                  session: webhookRecord(session, [
                    'id',
                    'userId',
                    'expiresAt',
                    'ipAddress',
                    'userAgent',
                    'createdAt',
                    'updatedAt',
                  ]),
                }),
            },
            delete: {
              after: async (session) =>
                options.publishWebhookEvent?.('session.revoked', {
                  session: webhookRecord(session, [
                    'id',
                    'userId',
                    'expiresAt',
                    'ipAddress',
                    'userAgent',
                    'createdAt',
                    'updatedAt',
                  ]),
                }),
            },
          }
        : undefined,
      account: {
        create: {
          before: async (account) => {
            if (account.providerId === 'credential') return { data: account }
            const existing = await externalResources.findActiveUserProviderConnectionByProviderSubject({
              providerId: account.providerId,
              externalSubject: account.accountId,
            })
            if (existing && existing.ownerUserId !== account.userId) {
              throw new APIError('CONFLICT', {
                message: 'This Provider account is already connected to another Realmroot account.',
              })
            }
            return { data: account }
          },
        },
      },
    },
    trustedOrigins,
    socialProviders: connectors.socialProviders,
    account: {
      accountLinking: {
        trustedProviders: connectors.trustedProviders,
        allowDifferentEmails: true,
      },
    },
    user: {
      additionalFields: {
        deletedAt: { type: 'date', required: false, input: false, returned: false },
        username: {
          type: 'string',
          required: false,
          unique: true,
          fieldName: 'username',
        },
        avatarAssetId: {
          type: 'string',
          required: false,
          fieldName: 'avatar_asset_id',
        },
      },
      changeEmail: {
        enabled: true,
      },
    },
    emailVerification: {
      sendOnSignUp: options.emailDeliveryReady ?? false,
      sendOnSignIn: options.emailDeliveryReady ?? false,
      sendVerificationEmail: async ({ user, url, token }) => {
        const verificationUrl = new URL(url)
        verificationUrl.searchParams.set('token', await bindEmailVerification(user.id, token, secret))
        await emailSender.send({
          to: user.email,
          template: {
            type: 'verification',
            url: verificationUrl.toString(),
          },
        })
      },
    },
    emailAndPassword: {
      enabled: true,
      requireEmailVerification: options.emailDeliveryReady ?? false,
      sendResetPassword: async ({ user, url }) => {
        await emailSender.send({
          to: user.email,
          template: {
            type: 'password-reset',
            url,
          },
        })
      },
      onPasswordReset: async ({ user }) => {
        sendPasswordChangedNotification(emailSender, user.email)
      },
      password: {
        hash: hashPassword,
        verify: ({ hash, password }) => verifyStoredPassword(hash, password),
      },
    },
    session: {
      expiresIn: securityPolicy.sessions.expiresInSeconds,
      updateAge: securityPolicy.sessions.updateAgeSeconds,
      freshAge: securityPolicy.sessions.freshAgeSeconds,
      cookieCache: {
        enabled: true,
        maxAge: securityPolicy.sessions.cookieCacheSeconds,
      },
    },
    plugins: [
      i18n({
        translations: betterAuthTranslations,
        defaultLocale: 'en',
        detection: ['cookie', 'header'],
        localeCookie: 'realmroot_locale',
      }),
      jwt({
        jwt: {
          issuer: `${baseURL}/api/auth`,
          audience: `${baseURL}/api/auth`,
        },
        jwks: {
          keyPairConfig: { alg: 'RS256', modulusLength: 2048 },
          gracePeriod: 60 * 60 * 24 * 30,
        },
      }),
      twoFactor({
        issuer: 'Realmroot',
        allowPasswordless: true,
        twoFactorCookieMaxAge: 60 * 10,
        trustDeviceMaxAge: 60 * 60 * 24 * 30,
        totpOptions: {
          disable: !securityPolicy.mfa.authenticatorAppEnabled,
        },
        ...(options.twoFactorEmailOtpEnabled
          ? {
              otpOptions: {
                sendOTP: async ({ user, otp }) => {
                  await emailSender.send({
                    to: user.email,
                    template: {
                      type: 'otp',
                      otp,
                    },
                  })
                },
              },
            }
          : {}),
      }),
      passkey({
        rpID: securityPolicy.passkeys.rpId,
        rpName: securityPolicy.passkeys.rpName,
        origin: securityPolicy.passkeys.origins,
        authenticatorSelection: {
          residentKey: 'preferred',
          userVerification: 'preferred',
        },
      }),
      agentAuth({
        providerName: 'Realmroot',
        providerDescription: 'Delegated Realmroot account access for approved agents.',
        modes: ['delegated'],
        approvalMethods: ['device_authorization'],
        deviceAuthorizationPage: '/agent/enrollment',
        allowDynamicHostRegistration: true,
        defaultHostCapabilities: [],
        requireAuthForCapabilities: false,
        capabilities: [],
        validateCapabilities: (capabilities) => capabilities.length === 0,
        consumeJti: async (jti, maxAgeSec) => {
          const now = new Date()
          return deps.agentTokens.consumeAgentAuthJti({
            jtiHash: await sha256Base64Url(jti),
            createdAt: now,
            expiresAt: new Date(now.getTime() + maxAgeSec * 1000),
          })
        },
      }),
      oauthDeviceAuthorization({
        verificationUri: '/auth/device',
        schema: {},
        ...createDeviceAuthorizationOptions(applications),
      }),
      emailOTP({
        ...(options.builtInProviders?.email.otpLength === undefined
          ? {}
          : { otpLength: options.builtInProviders.email.otpLength }),
        ...(options.builtInProviders?.email.expiresInSeconds === undefined
          ? {}
          : { expiresIn: options.builtInProviders.email.expiresInSeconds }),
        changeEmail: {
          enabled: true,
          verifyCurrentEmail: false,
        },
        sendVerificationOTP: async ({ email, otp }) => {
          await emailSender.send({
            to: email,
            template: {
              type: 'otp',
              otp,
            },
          })
        },
      }),
      ...(options.builtInProviders?.phone.enabled
        ? [
            phoneNumber({
              otpLength: options.builtInProviders.phone.otpLength,
              expiresIn: options.builtInProviders.phone.expiresInSeconds,
              requireVerification: options.builtInProviders.phone.requireVerification,
              signUpOnVerification: undefined,
              sendOTP: async ({ phoneNumber, code }) => {
                await sendSmsOtp(options.builtInProviders?.phone, phoneNumber, code)
              },
              sendPasswordResetOTP: async ({ phoneNumber, code }) => {
                await sendSmsOtp(options.builtInProviders?.phone, phoneNumber, code)
              },
            }),
          ]
        : []),
      ...(options.builtInProviders?.oneTap.enabled
        ? [
            oneTap({
              clientId: options.builtInProviders.oneTap.clientId || undefined,
              disableSignup: false,
            }),
          ]
        : []),
      ...(options.builtInProviders?.web3Wallet.enabled
        ? [
            siwe({
              domain: siweDomain(baseURL, options.builtInProviders.web3Wallet.domain),
              emailDomainName: options.builtInProviders.web3Wallet.emailDomainName || 'wallet.local',
              anonymous: true,
              getNonce: async () => createNonce(),
              verifyMessage: async ({ address, chainId, message, signature, cacao }) => {
                if (!options.builtInProviders?.web3Wallet.chains.includes(chainId)) return false
                const parsed = parseSiweMessage(message)
                const valid = validateSiweMessage({
                  address: address as `0x${string}`,
                  domain: siweDomain(baseURL, options.builtInProviders.web3Wallet.domain),
                  message: parsed,
                  nonce: cacao?.p.nonce,
                })
                if (!valid || parsed.chainId !== chainId) return false
                return verifyMessage({
                  address: address as `0x${string}`,
                  message,
                  signature: signature as `0x${string}`,
                })
              },
            }),
          ]
        : []),
      username({
        minUsernameLength: 3,
        maxUsernameLength: 64,
        usernameValidator: (value) => /^[a-zA-Z0-9_.-]+$/.test(value),
      }),
      organization({
        allowUserToCreateOrganization: async (user) => {
          const platform = await findPlatformOrganization(deps)
          return mayCreateOrganization(
            await configz.getOrganizationCreationPolicy(),
            { id: user.id, emailVerified: user.emailVerified },
            platform ? await organizationUserHasScope(deps, platform.id, user.id, 'organizations:write') : false,
          )
        },
        teams: {
          enabled: true,
          defaultTeam: {
            enabled: false,
          },
        },
        organizationHooks: {
          beforeCreateTeam: async ({ team }) => {
            await validateTeamName(db, team.organizationId, team.name)
          },
          beforeUpdateTeam: async ({ team, updates }) => {
            if (updates.name !== undefined) {
              await validateTeamName(db, team.organizationId, updates.name, team.id)
            }
          },
        },
        ac: organizationAccessControl,
        roles: organizationRoles,
        dynamicAccessControl: { enabled: true },
        sendInvitationEmail: async ({ email, id, inviter }) => {
          await emailSender.send({
            to: email,
            template: {
              type: 'invitation',
              inviterName: inviter.user.name,
              url: `${baseURL}/account/organizations?invitation=${encodeURIComponent(id)}`,
            },
          })
        },
      }),
      genericOAuth({
        config: connectors.genericOAuthProviders,
      }),
      oauthProvider({
        loginPage: '/auth/sign-in',
        consentPage: '/auth/consent',
        scopes: oauthScopes,
        resources: options.validAudiences,
        enforcePerClientResources: false,
        postLogin: {
          page: '/auth/context',
          consentReferenceId: async ({ referenceId, user }) => {
            if (!referenceId) return undefined
            if (referenceId === `user:${user.id}`) return referenceId
            if (!referenceId.startsWith('organization:')) {
              throw new APIError('FORBIDDEN', {
                error: 'access_denied',
                error_description: 'The selected authorization Context is invalid.',
              })
            }
            const organizationId = referenceId.slice('organization:'.length)
            if (!organizationId) {
              throw new APIError('FORBIDDEN', {
                error: 'access_denied',
                error_description: 'The selected authorization Context is invalid.',
              })
            }
            const [organization, membership] = await Promise.all([
              deps.authorization.findOrganization(organizationId),
              deps.authorization.findMemberByOrganizationUser(organizationId, user.id),
            ])
            if (!organization || organization.disabled || !membership) {
              throw new APIError('FORBIDDEN', {
                error: 'access_denied',
                error_description: 'The selected Organization Context is no longer available.',
              })
            }
            return referenceId
          },
          shouldRedirect: async ({ headers, user }) => {
            const clientId = headers.get('x-realmroot-oauth-client-id')
            if (!clientId) return false
            const application = await applications.findByClientId(clientId)
            if (!application) throw new Error('OAuth consent context does not reference an Application.')
            if (!(await applicationUserHasAccess(deps, application, user.id))) {
              throw new APIError('FORBIDDEN', {
                error: 'access_denied',
                error_description: 'The Application is unavailable to this user.',
              })
            }
            const resourceUrls = oauthResourceUrlsFromHeader(headers)
            if (resourceUrls.length === 0) return false
            const contexts = await resolveOAuthAuthorizationContexts(deps, application, resourceUrls, user)
            return contexts.length === 1 ? contexts[0]!.id : true
          },
        },
        filterAccessTokenScopes: (input) => filterOAuthAccessTokenScopes(deps, input),
        customAccessTokenClaims: (input) =>
          buildOAuthAccessTokenClaims(deps, { ...input, resource: input.resources?.[0] }),
        extensions: [
          {
            claims: {
              idToken: (input) => (input.user ? buildOAuthIdTokenClaims(deps, { ...input, user: input.user }) : {}),
            },
          },
        ],
        idTokenExpiresIn: 10 * 60,
        clientRegistrationDefaultScopes: ['openid', 'profile', 'email', 'groups'],
        clientRegistrationAllowedScopes: [...userConfigurableApplicationScopes],
        storeClientSecret: 'hashed',
        storeTokens: 'hashed',
      }),
    ],
  })
  const getSession = (async (input: Parameters<typeof auth.api.getSession>[0]) => {
    const result = await auth.api.getSession({
      ...input,
      headers: input!.headers!,
      asResponse: false,
      returnHeaders: false,
      returnStatus: false,
    })
    if (result) {
      const [live] = await db
        .select({ createdAt: schema.session.createdAt })
        .from(schema.session)
        .innerJoin(schema.user, eq(schema.user.id, schema.session.userId))
        .where(
          and(
            eq(schema.session.id, result.session.id),
            eq(schema.session.userId, result.user.id),
            isNull(schema.user.deletedAt),
          ),
        )
      if (!live) return input?.asResponse ? Response.json(null) : null
      result.session.createdAt = live.createdAt
    }
    if (input?.asResponse || input?.returnHeaders || input?.returnStatus)
      return auth.api.getSession({ ...input, headers: input.headers! })
    return result
  }) as typeof auth.api.getSession
  return {
    ...auth,
    api: { ...auth.api, getSession },
    handler: async (request: Request) => {
      const incomingUrl = new URL(request.url)
      if (incomingUrl.pathname.endsWith('/verify-email')) {
        const token = incomingUrl.searchParams.get('token')
        if (!token || !(await hasLiveEmailVerification(db, token, secret))) {
          return Response.json(
            {
              code: 'INVALID_TOKEN',
              message: 'Verification link is invalid or expired. Request a new verification email.',
            },
            { status: 401 },
          )
        }
      }
      // Native Better Auth routes also consume cached cookies; do not let a deleted
      // principal use them to read data, mint tokens, or mutate its tombstone.
      const cachedSession = await auth.api.getSession({ headers: request.headers, asResponse: false })
      if (cachedSession?.user) {
        const [current] = await db
          .select({ deletedAt: schema.user.deletedAt })
          .from(schema.user)
          .where(eq(schema.user.id, cachedSession.user.id))
        if (!current || current.deletedAt) {
          if (new URL(request.url).pathname.endsWith('/get-session')) return Response.json(null)
          if (!new URL(request.url).pathname.endsWith('/sign-out'))
            return Response.json(
              { code: 'ACCOUNT_DELETED', message: 'This account has been deleted.' },
              { status: 401 },
            )
        }
      }
      const deviceAdmissionError = await enforceDeviceApprovalAccess(
        request,
        (headers) => auth.api.getSession({ headers, asResponse: false }),
        db,
        applications,
        deps,
      )
      if (deviceAdmissionError) return deviceAdmissionError
      const response = await auth.handler(await withOAuthConsentContext(request))
      if (new URL(request.url).pathname.endsWith('/oauth2/introspect') && response.ok) {
        const result = (await response.clone().json()) as { active?: boolean; sub?: string }
        if (result.active && result.sub) {
          const [subject] = await db
            .select({ deletedAt: schema.user.deletedAt })
            .from(schema.user)
            .where(eq(schema.user.id, result.sub))
          if (subject?.deletedAt) return Response.json({ active: false }, { headers: { 'Cache-Control': 'no-store' } })
        }
      }
      return translateNonInteractiveConsentError(request, response)
    },
  }
}

async function enforceDeviceApprovalAccess(
  request: Request,
  getSession: (headers: Headers) => Promise<{ user: { id: string } } | null>,
  db: Database,
  applications: ApplicationRepository,
  deps: Deps,
) {
  const url = new URL(request.url)
  if (request.method !== 'POST' || !url.pathname.endsWith('/device/approve')) return null
  const body = (await request.clone().json()) as { userCode?: unknown }
  if (typeof body.userCode !== 'string') return null
  const [record] = await db
    .select({ clientId: schema.deviceCode.clientId })
    .from(schema.deviceCode)
    .where(eq(schema.deviceCode.userCode, body.userCode.replace(/-/g, '').toUpperCase()))
    .limit(1)
  if (!record?.clientId) return null
  const application = await applications.findByClientId(record.clientId)
  if (!application) return null
  const session = await getSession(request.headers)
  if (!session?.user.id) return null
  if (await applicationUserHasAccess(deps, application, session.user.id)) return null
  return Response.json(
    {
      error: 'access_denied',
      error_description: 'The Application is unavailable to this user.',
    },
    { status: 403 },
  )
}

async function validateTeamName(db: Database, organizationId: string, name: string, excludedTeamId?: string) {
  if (!teamNamePattern.test(name)) {
    throw new APIError('BAD_REQUEST', {
      message: 'Team names must use lowercase letters, numbers, and single hyphens.',
    })
  }
  const duplicate = await db
    .select({ id: schema.team.id })
    .from(schema.team)
    .where(
      and(
        eq(schema.team.organizationId, organizationId),
        eq(schema.team.name, name),
        excludedTeamId ? ne(schema.team.id, excludedTeamId) : undefined,
      ),
    )
    .limit(1)
  if (duplicate.length) {
    throw new APIError('CONFLICT', { message: 'A Team with this name already exists in the Organization.' })
  }
}

async function withOAuthConsentContext(request: Request) {
  const url = new URL(request.url)
  const params = await oauthAuthorizationParams(request, url)
  if (!params) return request

  const headers = new Headers(request.headers)
  const clientId = params.get('client_id')
  const resources = [...new Set(params.getAll('resource'))]
  if (clientId) headers.set('x-realmroot-oauth-client-id', clientId)
  else headers.delete('x-realmroot-oauth-client-id')
  if (resources.length) headers.set('x-realmroot-oauth-resources', JSON.stringify(resources))
  else headers.delete('x-realmroot-oauth-resources')
  return new Request(request, { headers })
}

function oauthResourceUrlsFromHeader(headers: Headers) {
  const encoded = headers.get('x-realmroot-oauth-resources')
  if (!encoded) return []
  const values: unknown = JSON.parse(encoded)
  if (!Array.isArray(values) || values.some((value) => typeof value !== 'string')) {
    throw new Error('OAuth consent context contains invalid Resource Server targets.')
  }
  return values as string[]
}

async function oauthAuthorizationParams(request: Request, url: URL) {
  if (request.method === 'GET' && url.pathname.endsWith('/oauth2/authorize')) return url.searchParams
  if (request.method !== 'POST' || !url.pathname.endsWith('/oauth2/consent')) return null
  if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) return null

  let body: unknown
  try {
    body = await request.clone().json()
  } catch {
    return null
  }
  if (!body || typeof body !== 'object' || Array.isArray(body)) return null
  const oauthQuery = (body as Record<string, unknown>).oauth_query
  return typeof oauthQuery === 'string' ? new URLSearchParams(oauthQuery) : null
}

function translateNonInteractiveConsentError(request: Request, response: Response) {
  const requestUrl = new URL(request.url)
  if (!new Set((requestUrl.searchParams.get('prompt') ?? '').split(/\s+/)).has('none')) return response

  const location = response.headers.get('location')
  if (!location) return response
  const redirect = new URL(location, request.url)
  if (redirect.searchParams.get('error') !== 'interaction_required') return response

  redirect.searchParams.set('error', 'consent_required')
  redirect.searchParams.set('error_description', 'End-User consent is required')
  const headers = new Headers(response.headers)
  headers.set('location', redirect.toString())
  return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
}

function webhookRecord(record: Record<string, unknown>, fields: string[]) {
  return Object.fromEntries(
    fields.filter((field) => record[field] !== undefined).map((field) => [field, record[field]]),
  )
}

export type Auth = ReturnType<typeof createAuth>

export function createDeviceAuthorizationOptions(applications: Pick<ApplicationRepository, 'findByClientId'>) {
  return {
    validateClient: async (clientId: string) => {
      const application = await applications.findByClientId(clientId)
      return (
        !!application &&
        !application.disabled &&
        application.public &&
        application.clientType === 'public_native' &&
        application.allowedGrantTypes.includes(deviceCodeGrantType)
      )
    },
    onDeviceAuthRequest: async (clientId: string, scope: string | undefined) => {
      const application = await applications.findByClientId(clientId)
      if (
        !application ||
        application.disabled ||
        !application.public ||
        application.clientType !== 'public_native' ||
        !application.allowedGrantTypes.includes(deviceCodeGrantType)
      ) {
        throw new APIError('BAD_REQUEST', {
          error: 'invalid_client',
          error_description: 'Invalid client ID',
        })
      }

      const requestedScopes = (scope || 'openid').split(/\s+/).filter(Boolean)
      for (const requestedScope of requestedScopes) {
        const protocolScopes = [
          ...application.oidcScopes,
          ...application.resourceScopes.flatMap((resource) => resource.scopes),
        ]
        if (!protocolScopes.includes(requestedScope)) {
          throw new APIError('BAD_REQUEST', {
            error: 'invalid_request',
            error_description: `Scope is not allowed for this client: ${requestedScope}`,
          })
        }
      }
    },
  }
}

async function sha256Base64Url(value: string) {
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
  return btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replaceAll('+', '-')
    .replaceAll('/', '_')
    .replace(/=+$/, '')
}