mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-09 02:56:02 +08:00
refactor(split): make the app self-hosting ready
Remove the transitional website auth, admin hooks, SEO and hosted analytics together, then align the app routes, runtime configuration, tests and dependencies. These changes form one atomic cleanup because the filtered app must switch its identity and runtime surfaces as a unit.
This commit is contained in:
@@ -1,89 +1,8 @@
|
||||
import { createError, getHeader, H3Event, setCookie, deleteCookie } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { randomBytes, timingSafeEqual, scrypt as _scrypt } from 'node:crypto'
|
||||
import { promisify } from 'node:util'
|
||||
import type { UserDocument } from '../models/User'
|
||||
import { User } from '../models/User'
|
||||
import { AppUser } from '../models/AppUser'
|
||||
import { createJwtToken, verifyJwtToken } from './jwt'
|
||||
import { getAuthMode, getLocalAppUser, mirrorAppUser } from './authMode'
|
||||
import { createError, getHeader, type H3Event } from 'h3'
|
||||
import { AppUser, type AppUserDocument } from '../models/AppUser'
|
||||
import { getAuthMode, getLocalAppUser } from './authMode'
|
||||
import { readAppSession, verifyAppAccessToken } from './session'
|
||||
|
||||
const scrypt = promisify(_scrypt) as (password: string | Buffer, salt: string | Buffer, keylen: number) => Promise<Buffer>
|
||||
|
||||
const ACCESS_TOKEN_TTL_SECONDS = 60 * 60 * 24
|
||||
const REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 7
|
||||
const REFRESH_COOKIE_NAME = 'os_refresh_token'
|
||||
const PASSWORD_SALT_BYTES = 16
|
||||
const PASSWORD_KEYLEN = 64
|
||||
|
||||
function getSecrets() {
|
||||
const config = useRuntimeConfig()
|
||||
if (!config.jwtSecret) {
|
||||
throw new Error('JWT secret missing – bitte JWT_SECRET in .env setzen')
|
||||
}
|
||||
return {
|
||||
accessSecret: config.jwtSecret as string,
|
||||
refreshSecret: (config.jwtRefreshSecret as string) || (config.jwtSecret as string),
|
||||
}
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
const salt = randomBytes(PASSWORD_SALT_BYTES)
|
||||
const derived = await scrypt(password, salt, PASSWORD_KEYLEN)
|
||||
return `${salt.toString('hex')}.${derived.toString('hex')}`
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, stored: string) {
|
||||
const [saltHex, hashHex] = stored.split('.')
|
||||
if (!saltHex || !hashHex) return false
|
||||
const salt = Buffer.from(saltHex, 'hex')
|
||||
const expected = Buffer.from(hashHex, 'hex')
|
||||
const derived = await scrypt(password, salt, expected.length)
|
||||
if (derived.length !== expected.length) return false
|
||||
return timingSafeEqual(derived, expected)
|
||||
}
|
||||
|
||||
export function createAccessToken(user: UserDocument) {
|
||||
const { accessSecret } = getSecrets()
|
||||
return createJwtToken(
|
||||
{
|
||||
sub: String(user._id),
|
||||
email: user.email,
|
||||
version: user.tokenVersion,
|
||||
},
|
||||
accessSecret,
|
||||
ACCESS_TOKEN_TTL_SECONDS,
|
||||
)
|
||||
}
|
||||
|
||||
export function createRefreshToken(user: UserDocument) {
|
||||
const { refreshSecret } = getSecrets()
|
||||
return createJwtToken(
|
||||
{
|
||||
sub: String(user._id),
|
||||
type: 'refresh',
|
||||
version: user.tokenVersion,
|
||||
},
|
||||
refreshSecret,
|
||||
REFRESH_TOKEN_TTL_SECONDS,
|
||||
)
|
||||
}
|
||||
|
||||
export function setRefreshTokenCookie(event: H3Event, token: string) {
|
||||
setCookie(event, REFRESH_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: REFRESH_TOKEN_TTL_SECONDS,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
})
|
||||
}
|
||||
|
||||
export function clearRefreshTokenCookie(event: H3Event) {
|
||||
deleteCookie(event, REFRESH_COOKIE_NAME, { path: '/' })
|
||||
}
|
||||
|
||||
function parseAuthorizationHeader(event: H3Event) {
|
||||
const header = getHeader(event, 'authorization')
|
||||
if (!header) return null
|
||||
@@ -92,39 +11,28 @@ function parseAuthorizationHeader(event: H3Event) {
|
||||
return token
|
||||
}
|
||||
|
||||
// Local-dev-only bypass session (server/api/dev/login.post.ts): a fixed,
|
||||
// entirely in-memory "user" that never touches MongoDB, so require-auth
|
||||
// pages are reachable for local testing even when the dev DB is unreachable.
|
||||
// This fixed ObjectId never resolves to a real User document: resolveUserFromToken
|
||||
// below matches it BEFORE ever calling User.findById. It must nevertheless be a
|
||||
// valid ObjectId because BridgeToken.user is stored as an ObjectId when WebSim
|
||||
// connects to the real bridge endpoints.
|
||||
// Local-dev-only identity used by server/api/dev/login.post.ts. The fixed
|
||||
// ObjectId remains bridge-compatible and stable across development sessions.
|
||||
export const DEV_BYPASS_USER_ID = '000000000000000000000001'
|
||||
const DEV_BYPASS_EMAIL = 'dev-claude@localhost.test'
|
||||
|
||||
export function getDevBypassUser(): UserDocument {
|
||||
export function getDevBypassUser() {
|
||||
return {
|
||||
_id: DEV_BYPASS_USER_ID,
|
||||
ssoSubject: DEV_BYPASS_USER_ID,
|
||||
email: DEV_BYPASS_EMAIL,
|
||||
name: 'Dev Test User',
|
||||
role: 'user',
|
||||
tokenVersion: 0,
|
||||
role: 'user' as const,
|
||||
createdAt: new Date(0),
|
||||
invitationCodesIssued: 0,
|
||||
acceptedTermsAt: new Date(0),
|
||||
acceptedPrivacyAt: new Date(0),
|
||||
} as unknown as UserDocument
|
||||
updatedAt: new Date(0),
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve an identity from the app's own mirror — no `User` lookup involved. */
|
||||
async function resolveAppUser(sub: string) {
|
||||
const appUser = await AppUser.findById(sub)
|
||||
if (!appUser) return null
|
||||
return appUser as unknown as UserDocument
|
||||
async function resolveAppUser(sub: string): Promise<AppUserDocument | null> {
|
||||
return await AppUser.findById(sub)
|
||||
}
|
||||
|
||||
export async function resolveUserFromToken(event: H3Event) {
|
||||
// 1. The app's own session cookie — the only path that survives the split.
|
||||
export async function resolveUserFromToken(event: H3Event): Promise<AppUserDocument | null> {
|
||||
const session = readAppSession(event)
|
||||
if (session) {
|
||||
const appUser = await resolveAppUser(session.sub)
|
||||
@@ -134,58 +42,21 @@ export async function resolveUserFromToken(event: H3Event) {
|
||||
const token = parseAuthorizationHeader(event)
|
||||
if (!token) return null
|
||||
|
||||
// 2. An app-minted bearer token (same session, carried in the header).
|
||||
const appToken = verifyAppAccessToken(token)
|
||||
if (appToken) {
|
||||
return await resolveAppUser(appToken.sub)
|
||||
}
|
||||
|
||||
// 3. PHASE 1 (app repo): everything below goes away together with the `User`
|
||||
// collection. While website and app share one deployment, a website access
|
||||
// token is still a valid way in — it is how every existing user is logged in
|
||||
// today. Each such request also refreshes the AppUser mirror (rate-limited
|
||||
// internally), so app-side data is already keyed correctly when the split
|
||||
// happens.
|
||||
try {
|
||||
const { accessSecret } = getSecrets()
|
||||
const payload = verifyJwtToken(token, accessSecret)
|
||||
if (!payload?.sub) return null
|
||||
if (payload.sub === DEV_BYPASS_USER_ID && process.env.NODE_ENV !== 'production') {
|
||||
const devUser = getDevBypassUser()
|
||||
await mirrorAppUser({
|
||||
subject: DEV_BYPASS_USER_ID,
|
||||
email: devUser.email,
|
||||
name: devUser.name,
|
||||
role: 'user',
|
||||
}).catch(() => null)
|
||||
return devUser
|
||||
}
|
||||
const user = await User.findById(payload.sub)
|
||||
if (!user) return null
|
||||
if (typeof payload.version === 'number' && payload.version !== user.tokenVersion) {
|
||||
return null
|
||||
}
|
||||
await mirrorAppUser({
|
||||
subject: String(user._id),
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
}).catch(() => null)
|
||||
return user
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function requireUserSession(event: H3Event) {
|
||||
export async function requireUserSession(event: H3Event): Promise<AppUserDocument> {
|
||||
if (event.context?.user) {
|
||||
return event.context.user as UserDocument
|
||||
return event.context.user as AppUserDocument
|
||||
}
|
||||
|
||||
// AUTH_MODE=open: a self-hosted instance has no login. Every request is the
|
||||
// one local identity, resolved without ever touching the `User` collection.
|
||||
if (getAuthMode() === 'open') {
|
||||
const localUser = await getLocalAppUser() as unknown as UserDocument
|
||||
const localUser = await getLocalAppUser()
|
||||
event.context.user = localUser
|
||||
return localUser
|
||||
}
|
||||
@@ -198,10 +69,10 @@ export async function requireUserSession(event: H3Event) {
|
||||
return user
|
||||
}
|
||||
|
||||
export async function getUserFromEvent(event: H3Event) {
|
||||
if (event.context?.user) return event.context.user as UserDocument
|
||||
export async function getUserFromEvent(event: H3Event): Promise<AppUserDocument | null> {
|
||||
if (event.context?.user) return event.context.user as AppUserDocument
|
||||
if (getAuthMode() === 'open') {
|
||||
const localUser = await getLocalAppUser() as unknown as UserDocument
|
||||
const localUser = await getLocalAppUser()
|
||||
event.context.user = localUser
|
||||
return localUser
|
||||
}
|
||||
@@ -211,56 +82,3 @@ export async function getUserFromEvent(event: H3Event) {
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
export function hasAdminRole(user: UserDocument | null | undefined) {
|
||||
return user ? user.role === 'admin' || user.role === 'dev' : false
|
||||
}
|
||||
|
||||
export async function requireAdmin(event: H3Event) {
|
||||
// PHASE 1 (app repo): delete this guard together with the admin surface.
|
||||
// The local AUTH_MODE=open identity is an admin *of its own instance*, which
|
||||
// is correct once /api/admin/** no longer lives here. For as long as it does,
|
||||
// an unauthenticated open-mode request must never reach it.
|
||||
if (getAuthMode() === 'open') {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Not found' })
|
||||
}
|
||||
const user = await requireUserSession(event)
|
||||
if (!hasAdminRole(user)) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Administratorrechte erforderlich' })
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
export async function issueAuthTokens(event: H3Event, user: UserDocument) {
|
||||
const accessToken = createAccessToken(user)
|
||||
const refreshToken = createRefreshToken(user)
|
||||
setRefreshTokenCookie(event, refreshToken)
|
||||
return { accessToken }
|
||||
}
|
||||
|
||||
export async function rotateRefreshToken(event: H3Event) {
|
||||
const cookieHeader = event.node.req.headers?.cookie || ''
|
||||
const match = cookieHeader.split(';').map((p) => p.trim()).find((p) => p.startsWith(`${REFRESH_COOKIE_NAME}=`))
|
||||
if (!match) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'No refresh token present' })
|
||||
}
|
||||
const token = match.substring(REFRESH_COOKIE_NAME.length + 1)
|
||||
try {
|
||||
const { refreshSecret } = getSecrets()
|
||||
const payload = verifyJwtToken(token, refreshSecret)
|
||||
if (!payload?.sub || payload.type !== 'refresh') {
|
||||
throw new Error('Invalid token payload')
|
||||
}
|
||||
const user = await User.findById(payload.sub)
|
||||
if (!user) {
|
||||
throw new Error('User missing')
|
||||
}
|
||||
if (typeof payload.version === 'number' && payload.version !== user.tokenVersion) {
|
||||
throw new Error('Token version mismatch')
|
||||
}
|
||||
return issueAuthTokens(event, user)
|
||||
} catch (err) {
|
||||
clearRefreshTokenCookie(event)
|
||||
throw createError({ statusCode: 401, statusMessage: 'Refresh token invalid' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export type AuthMode = 'open' | 'sso'
|
||||
*
|
||||
* There is deliberately no third mode: the app never owns passwords, invites or
|
||||
* password resets. Entitlement/feature-gating must NOT be mixed in here —
|
||||
* AUTH_MODE is an *identity* concept (see Phase 5.2 of the split plan).
|
||||
* AUTH_MODE is an identity concept.
|
||||
*/
|
||||
|
||||
// The single local identity used in AUTH_MODE=open. Fixed so that profiles,
|
||||
@@ -24,14 +24,7 @@ const LOCAL_USER_EMAIL = 'local@opensquawk.invalid'
|
||||
const LOCAL_USER_NAME = 'Local User'
|
||||
|
||||
export function getAuthMode(): AuthMode {
|
||||
// PHASE 1 (app repo): flip this default to 'open' and delete the note below.
|
||||
//
|
||||
// The default is 'sso' for as long as the website surface (/api/admin/**,
|
||||
// /api/editor/**, invitations, waitlist) still lives in this repo. Defaulting
|
||||
// to 'open' here would mean that any deployment which simply forgets to set
|
||||
// AUTH_MODE serves those endpoints to everyone as the local admin user.
|
||||
// In the app repo that surface is gone and 'open' is the correct default.
|
||||
const raw = (process.env.AUTH_MODE || 'sso').trim().toLowerCase()
|
||||
const raw = (process.env.AUTH_MODE || 'open').trim().toLowerCase()
|
||||
return raw === 'open' ? 'open' : 'sso'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getHeader, type H3Event } from 'h3'
|
||||
import mongoose from 'mongoose'
|
||||
import { AppUser, type AppUserDocument } from '../models/AppUser'
|
||||
import { mirrorAppUser } from './authMode'
|
||||
|
||||
export function normalizeBridgeToken(input: unknown) {
|
||||
if (typeof input !== 'string') {
|
||||
@@ -21,18 +20,7 @@ export function getBridgeTokenFromHeader(event: H3Event) {
|
||||
return normalizeBridgeToken(getHeader(event, 'x-bridge-token'))
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity behind a bridge token, from the app's own mirror.
|
||||
*
|
||||
* Bridge endpoints are called by the desktop app with only a token, so there is
|
||||
* no session to resolve and no chance to refresh the mirror first. A token
|
||||
* paired before the mirror existed would otherwise report "not connected" and
|
||||
* push the user through pairing again, so a missing mirror row is backfilled
|
||||
* from the website's User here rather than treated as "unknown user".
|
||||
*
|
||||
* PHASE 1 (app repo): drop the backfill — by then every token predates nothing
|
||||
* and there is no User collection to read.
|
||||
*/
|
||||
/** Resolve the identity behind a bridge token from the app's own user store. */
|
||||
export async function resolveBridgeUser(
|
||||
user: unknown,
|
||||
): Promise<AppUserDocument | null> {
|
||||
@@ -46,17 +34,5 @@ export async function resolveBridgeUser(
|
||||
const userId = String((user as { _id?: unknown })?._id ?? user)
|
||||
if (!mongoose.isValidObjectId(userId)) return null
|
||||
|
||||
const mirrored = await AppUser.findById(userId)
|
||||
if (mirrored) return mirrored
|
||||
|
||||
const { User } = await import('../models/User')
|
||||
const websiteUser = await User.findById(userId)
|
||||
if (!websiteUser) return null
|
||||
|
||||
return await mirrorAppUser({
|
||||
subject: String(websiteUser._id),
|
||||
email: websiteUser.email,
|
||||
name: websiteUser.name,
|
||||
role: websiteUser.role,
|
||||
}, { force: true })
|
||||
return await AppUser.findById(userId)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
const ADMIN_EMAIL_FALLBACK = 'info@opensquawk.de'
|
||||
|
||||
interface MailOptions {
|
||||
to: string
|
||||
subject: string
|
||||
@@ -13,17 +11,6 @@ interface MailPayload extends MailOptions {
|
||||
from: string
|
||||
}
|
||||
|
||||
type NotificationDataEntry = readonly [string, ...unknown[]]
|
||||
|
||||
interface AdminNotificationInput {
|
||||
event: string
|
||||
summary?: string
|
||||
message?: string
|
||||
data?: NotificationDataEntry[]
|
||||
from?: string
|
||||
replyTo?: string
|
||||
}
|
||||
|
||||
interface SmtpConfig {
|
||||
host: string
|
||||
port: number
|
||||
@@ -33,7 +20,7 @@ interface SmtpConfig {
|
||||
}
|
||||
|
||||
function resolveFrom(from?: string) {
|
||||
return from || process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk <no-reply@opensquawk.dev>'
|
||||
return from || process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk <no-reply@localhost>'
|
||||
}
|
||||
|
||||
function resolveSmtpConfig(): SmtpConfig | null {
|
||||
@@ -111,81 +98,3 @@ export async function sendMail(options: MailOptions) {
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
function formatNotificationValue(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '—'
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
||||
return String(value)
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString()
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => formatNotificationValue(entry)).join(', ')
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch (error) {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function formatAdminNotification(notification: AdminNotificationInput) {
|
||||
const summary = notification.summary?.trim()
|
||||
const subject = summary && summary.length > 0 ? summary : notification.event
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push(subject)
|
||||
lines.push('')
|
||||
lines.push(`Event: ${notification.event}`)
|
||||
|
||||
const message = notification.message?.trim()
|
||||
if (message) {
|
||||
lines.push('')
|
||||
lines.push(message)
|
||||
}
|
||||
|
||||
if (notification.data?.length) {
|
||||
lines.push('')
|
||||
lines.push('Details:')
|
||||
for (const entry of notification.data) {
|
||||
const [label, ...values] = entry
|
||||
const formattedValue = values.length
|
||||
? values.map((value) => formatNotificationValue(value)).join(' | ')
|
||||
: '—'
|
||||
lines.push(`- ${label}: ${formattedValue}`)
|
||||
}
|
||||
}
|
||||
|
||||
return { subject, text: lines.join('\n'), from: notification.from, replyTo: notification.replyTo }
|
||||
}
|
||||
|
||||
export async function sendAdminNotification(notification: string | AdminNotificationInput, text?: string) {
|
||||
const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
|
||||
|
||||
let mailOptions: MailOptions
|
||||
|
||||
if (typeof notification === 'string') {
|
||||
mailOptions = { to, subject: notification, text: text || '' }
|
||||
} else {
|
||||
const formatted = formatAdminNotification(notification)
|
||||
mailOptions = {
|
||||
to,
|
||||
subject: formatted.subject,
|
||||
text: formatted.text,
|
||||
from: formatted.from,
|
||||
replyTo: formatted.replyTo,
|
||||
}
|
||||
}
|
||||
|
||||
const success = await sendMail(mailOptions)
|
||||
if (!success) {
|
||||
console.info(`[notify:fallback] ${mailOptions.subject}\nRecipient: ${to}\n${mailOptions.text}`)
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
@@ -7,21 +7,16 @@ import type { AppUserDocument } from '../models/AppUser'
|
||||
*
|
||||
* Once an identity has arrived (via the SSO exchange, or trivially in
|
||||
* AUTH_MODE=open), the app mints a session signed with its own secret and
|
||||
* stored in a host-only cookie on its own origin. No cross-domain cookie, no
|
||||
* CORS, no shared secret with the website beyond the one-time exchange. After
|
||||
* this point the app can serve every request without the issuer being reachable
|
||||
* at all — which is the whole point: a self-hosted instance must not depend on
|
||||
* opensquawk.de.
|
||||
* stored in a host-only cookie on its own origin. No cross-domain cookie or
|
||||
* CORS is needed. After this point the app can serve every request without the
|
||||
* optional issuer being reachable.
|
||||
*/
|
||||
|
||||
const APP_SESSION_COOKIE = 'os_app_session'
|
||||
const APP_SESSION_TTL_SECONDS = 60 * 60 * 24 * 30
|
||||
const APP_ACCESS_TOKEN_TTL_SECONDS = 60 * 60 * 24
|
||||
|
||||
// Marks a bearer token as minted by the app rather than by the website's
|
||||
// login. Both are HS256 and — in the monorepo, where APP_JWT_SECRET is usually
|
||||
// unset — signed with the same secret, so the payload is what tells them apart:
|
||||
// an app token resolves against AppUser, a website token against User.
|
||||
// Marks a bearer token as minted by this app.
|
||||
const APP_TOKEN_TYPE = 'app'
|
||||
|
||||
export interface AppSessionPayload {
|
||||
@@ -32,8 +27,7 @@ export interface AppSessionPayload {
|
||||
}
|
||||
|
||||
function getAppSessionSecret() {
|
||||
// APP_JWT_SECRET lets the app run on a secret of its own; JWT_SECRET is the
|
||||
// fallback so the monorepo keeps working with a single configured secret.
|
||||
// JWT_SECRET remains a compatibility fallback for existing installations.
|
||||
const secret = (process.env.APP_JWT_SECRET || process.env.JWT_SECRET || '').trim()
|
||||
if (!secret) {
|
||||
throw new Error('App session secret missing – bitte APP_JWT_SECRET (oder JWT_SECRET) in .env setzen')
|
||||
@@ -53,18 +47,14 @@ function sessionClaims(user: AppUserDocument) {
|
||||
|
||||
/**
|
||||
* Short-lived bearer token for the browser. The durable session lives in the
|
||||
* httpOnly cookie; this is what the client puts in the Authorization header,
|
||||
* exactly as the website's access token does today, so no call site has to
|
||||
* change shape.
|
||||
* httpOnly cookie; this is what the client puts in the Authorization header.
|
||||
*/
|
||||
export function createAppAccessToken(user: AppUserDocument) {
|
||||
return createJwtToken(sessionClaims(user), getAppSessionSecret(), APP_ACCESS_TOKEN_TTL_SECONDS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the payload if `token` is an app-minted bearer token, else null —
|
||||
* including for a well-formed website token, which must fall through to the
|
||||
* website resolution path.
|
||||
* Returns the payload if `token` is an app-minted bearer token, else null.
|
||||
*/
|
||||
export function verifyAppAccessToken(token: string): AppSessionPayload | null {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user