server/auth.ts
Rama main · réplica de lectura del repositorio original.
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(/=+$/, '')
}