@cantera/acc-auth-routeskit
The headless half of Autodesk sign-in on aec-auth: consent start, code exchange, and sign-out route handlers, the signed session library, and the scoped sign-in component — no pages.
npx shadcn@latest add @cantera/acc-auth-routesInstalled: the /api/auth/* route handlers, lib/acc-auth.ts (aec-auth vault wiring and the signed session cookie), and ScopedAutodeskSignIn. No page: pair with @cantera/acc-sign-in, or mount the component with a nextPath.
Next: 1. Fill .env.local: APS_CLIENT_ID and APS_CLIENT_SECRET from aps.autodesk.com; SESSION_SECRET from `openssl rand -base64 32` (required in production); APP_ORIGIN, your canonical public origin (required in production); APS_AUTH_BASE_URL only for an emulator. 2. Grants live in memory until UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, and VAULT_KEY (`openssl rand -base64 32`) are set; then they persist in Upstash Redis, encrypted. Set all three before real users connect. Reference: https://canteraui.vercel.app/components/acc-auth-routes
This is the exact code the CLI installs into your project — you own it from there.
import { APS_AUTH, TokenError, type TokenSource } from 'aec-auth'
import {
apsOAuth,
deleteUserGrant,
encryptedVaultStore,
memoryVaultStore,
saveUserGrant,
type VaultStore,
vaultTokenSource,
} from 'aec-auth/vault'
import { upstashVaultStore } from 'aec-auth/vault/upstash'
import { cache } from 'react'
/**
* Environment: APS_CLIENT_ID / APS_CLIENT_SECRET, optional APS_AUTH_BASE_URL
* (absolute, or relative like "/emulate/aps" for the emulator; unset = real
* APS), APP_ORIGIN and SESSION_SECRET — required in production.
*
* The vault store is in-memory until UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN,
* and VAULT_KEY are all set; then grants persist in Upstash Redis, encrypted at rest.
*/
export const APS_PROVIDER_ID = 'aps'
export const DEFAULT_SIGN_IN_SCOPES = ['user-profile:read', 'data:read', 'viewables:read']
export const ALLOWED_SIGN_IN_SCOPES = new Set([
...DEFAULT_SIGN_IN_SCOPES,
'data:write',
'data:create',
'account:read',
'account:write',
])
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 14
const globalStore = globalThis as { __accVaultStore?: VaultStore }
// Serverless hosts run many instances and recycle them, and the memory store
// forgets a grant the moment another instance answers. Durable storage is
// opt-in through the environment so a first deploy still boots without it.
function createVaultStore(): VaultStore {
const url = process.env.UPSTASH_REDIS_REST_URL
const token = process.env.UPSTASH_REDIS_REST_TOKEN
const key = process.env.VAULT_KEY
if (url && token && key) return encryptedVaultStore(upstashVaultStore({ url, token }), { key })
return memoryVaultStore()
}
export function getVaultStore(): VaultStore {
globalStore.__accVaultStore ??= createVaultStore()
return globalStore.__accVaultStore
}
// Vercel sets VERCEL_PROJECT_PRODUCTION_URL on every deployment, so a one-click
// deploy has a trusted origin before its owner knows the URL.
function deploymentOrigin(): string | undefined {
if (process.env.APP_ORIGIN) return process.env.APP_ORIGIN
const vercel = process.env.VERCEL_PROJECT_PRODUCTION_URL
return vercel ? `https://${vercel}` : undefined
}
export function appOrigin(requestOrigin: string): string {
const configured = deploymentOrigin()
if (!configured) {
if (process.env.NODE_ENV === 'production') {
throw new Error('APP_ORIGIN is required in production')
}
return new URL(requestOrigin).origin
}
const url = new URL(configured)
if (
!['http:', 'https:'].includes(url.protocol) ||
url.pathname !== '/' ||
url.search ||
url.hash
) {
throw new Error('APP_ORIGIN must be an HTTP(S) origin without a path, query, or fragment')
}
return url.origin
}
function resolveAuthBase(origin: string): string | undefined {
const configured = process.env.APS_AUTH_BASE_URL
if (!configured) return undefined
return configured.startsWith('/') ? `${origin}${configured}` : configured
}
export function getApsOAuth(origin: string) {
const clientId = process.env.APS_CLIENT_ID
if (!clientId) {
throw new TokenError('not_configured', 'aps', 'APS_CLIENT_ID is not set')
}
return apsOAuth({
clientId,
clientSecret: process.env.APS_CLIENT_SECRET,
baseUrl: resolveAuthBase(origin),
})
}
export function getTokenSource(origin: string): TokenSource {
return vaultTokenSource({
store: getVaultStore(),
providers: { aps: getApsOAuth(origin) },
})
}
// React.cache dedupes by argument identity: pass the session object
// `openSession` returned, which is per-request stable for the same reason.
export const getSessionToken = cache((origin: string, session: AccSession) =>
getTokenSource(origin).getToken({
provider: APS_PROVIDER_ID,
subject: { type: 'user', id: session.userId },
scopes: session.scopes,
}),
)
export function userInfoUrl(origin: string): string {
const base = resolveAuthBase(origin)
return base ? `${base}/userinfo` : APS_AUTH.userInfoUrl
}
export { deleteUserGrant, saveUserGrant }
export interface AccSession {
userId: string
name?: string
email?: string
avatarUrl?: string
scopes?: string[]
}
interface SessionPayload extends AccSession {
expiresAt: number
}
export const SESSION_COOKIE = 'acc-session'
const STATE_COOKIE = 'acc-oauth-state'
function sessionSecret(): string {
const secret = process.env.SESSION_SECRET
if (secret) return secret
// The known fallback is tolerable only where forged sessions do not matter:
// local development, or ACC_AUTH_DEMO=1 (an emulator-backed showcase).
// Everywhere else fail closed — a shared key lets anyone mint any session.
if (process.env.NODE_ENV !== 'production' || process.env.ACC_AUTH_DEMO === '1') {
return 'cantera-demo-insecure-secret'
}
throw new Error(
'SESSION_SECRET is not set. Generate one (e.g. `openssl rand -base64 32`) and set it in production.',
)
}
const encoder = new TextEncoder()
function toBase64Url(bytes: Uint8Array): string {
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
}
function fromBase64Url(value: string): Uint8Array {
const padded = value.replaceAll('-', '+').replaceAll('_', '/')
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0))
}
let importedHmacKey: { secret: string; key: Promise<CryptoKey> } | undefined
function hmacKey(): Promise<CryptoKey> {
const secret = sessionSecret()
if (importedHmacKey?.secret !== secret) {
importedHmacKey = {
secret,
key: crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify'],
),
}
}
return importedHmacKey.key
}
async function hmac(payload: string): Promise<string> {
const signature = await crypto.subtle.sign('HMAC', await hmacKey(), encoder.encode(payload))
return toBase64Url(new Uint8Array(signature))
}
export async function sealSession(
session: AccSession,
maxAgeSeconds = SESSION_MAX_AGE_SECONDS,
): Promise<string> {
const payload = toBase64Url(
encoder.encode(JSON.stringify({ ...session, expiresAt: Date.now() + maxAgeSeconds * 1000 })),
)
return `${payload}.${await hmac(payload)}`
}
async function verifyHmac(payload: string, signature: string): Promise<boolean> {
try {
const decoded = fromBase64Url(signature)
const signatureBytes = new Uint8Array(decoded.byteLength)
signatureBytes.set(decoded)
return await crypto.subtle.verify(
'HMAC',
await hmacKey(),
signatureBytes,
encoder.encode(payload),
)
} catch {
return false
}
}
export async function verifySealedSession(
cookieValue: string | undefined,
): Promise<AccSession | null> {
if (!cookieValue) return null
const [payload, signature] = cookieValue.split('.')
if (!payload || !signature || !(await verifyHmac(payload, signature))) return null
try {
const session = JSON.parse(new TextDecoder().decode(fromBase64Url(payload))) as SessionPayload
if (
typeof session.userId !== 'string' ||
session.userId.length === 0 ||
!Number.isFinite(session.expiresAt) ||
session.expiresAt <= Date.now()
) {
return null
}
const account: AccSession = { userId: session.userId }
if (typeof session.name === 'string') account.name = session.name
if (typeof session.email === 'string') account.email = session.email
if (typeof session.avatarUrl === 'string') account.avatarUrl = session.avatarUrl
if (
Array.isArray(session.scopes) &&
session.scopes.every((scope) => typeof scope === 'string')
) {
account.scopes = session.scopes
}
return account
} catch {
return null
}
}
// React.cache: a page plus the panels it mounts verify one HMAC per request
// instead of one each.
export const openSession = cache(
async (cookieValue: string | undefined): Promise<AccSession | null> =>
verifySealedSession(cookieValue),
)
/** `Secure` only for HTTPS requests, so local plain-HTTP development stays usable. */
export function cookieSecurity(requestUrl: URL | string): string {
const url = typeof requestUrl === 'string' ? new URL(requestUrl) : requestUrl
return url.protocol === 'https:' ? '; Secure' : ''
}
export function sessionCookie(
value: string,
secure: string,
maxAgeSeconds = SESSION_MAX_AGE_SECONDS,
): string {
return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}${secure}`
}
export function clearSessionCookie(secure: string): string {
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`
}
export interface OAuthState {
state: string
next: string
/** Scopes requested at the start of the flow, kept for providers (and
* emulators) whose token response omits the granted scope list. */
scopes?: string[]
}
export function newState(next: string, scopes?: string[]): OAuthState {
const bytes = crypto.getRandomValues(new Uint8Array(24))
return { state: toBase64Url(bytes), next, scopes }
}
export function stateCookie(oauthState: OAuthState, secure: string): string {
const value = toBase64Url(encoder.encode(JSON.stringify(oauthState)))
return `${STATE_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600${secure}`
}
export function clearStateCookie(secure: string): string {
return `${STATE_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`
}
const STATE_COOKIE_PATTERN = new RegExp(`(?:^|;\\s*)${STATE_COOKIE}=([^;]+)`)
export function readStateCookie(cookieHeader: string | null): OAuthState | null {
const match = cookieHeader?.match(STATE_COOKIE_PATTERN)
if (!match) return null
try {
return JSON.parse(new TextDecoder().decode(fromBase64Url(match[1]))) as OAuthState
} catch {
return null
}
}
export function safeNext(next: string | null | undefined, fallback: string): string {
// Same-origin relative paths only. `//` is protocol-relative, and browsers
// normalize backslashes in a Location header ("/\\evil.com" -> "//evil.com"),
// so both are external redirects in disguise.
if (next?.startsWith('/') && !next.startsWith('//') && !next.includes('\\')) return next
return fallback
}
export function allowedSignInScopes(scopesParam: string | null): string[] {
const requested = scopesParam?.split(/[\s,]+/).filter(Boolean) ?? []
return [...new Set([...DEFAULT_SIGN_IN_SCOPES, ...requested])].filter((scope) =>
ALLOWED_SIGN_IN_SCOPES.has(scope),
)
}
'use client'
import type { ReactNode } from 'react'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { ProviderSignInLink } from '@/components/ui/provider-sign-in-button'
import { ScopePicker, withRequiredScopes } from '@/components/ui/scope-picker'
import { apsProvider, apsScopeCatalog, apsScopePresets } from '@/lib/aps-oauth-preset'
import type { OAuthScopePreset } from '@/lib/oauth-types'
const REQUIRED_SCOPE_IDS = new Set(['user-profile:read', 'data:read', 'viewables:read'])
const OPTIONAL_SCOPE_IDS = new Set(['data:write', 'data:create', 'account:read', 'account:write'])
const modelViewerScopes = apsScopeCatalog
.filter((scope) => REQUIRED_SCOPE_IDS.has(scope.id) || OPTIONAL_SCOPE_IDS.has(scope.id))
.map((scope) => (REQUIRED_SCOPE_IDS.has(scope.id) ? { ...scope, required: true } : scope))
const modelViewerPresets: OAuthScopePreset[] = apsScopePresets
.filter((preset) => ['viewer', 'data-write', 'account-admin'].includes(preset.id))
.map((preset) => {
if (preset.id === 'viewer') {
return {
...preset,
label: 'View models',
description: 'Browse projects and view translated models.',
}
}
if (preset.id === 'data-write') {
return {
...preset,
label: 'Manage files',
description: 'Create and update project files and folders.',
}
}
return {
...preset,
label: 'Account administration',
description: 'Read and manage ACC account settings.',
}
})
interface ScopedAutodeskSignInProps {
nextPath: string
title?: ReactNode
titleAs?: 'h1' | 'h2' | 'h3'
description?: ReactNode
/** Where the sign-in starts. Demos point it at an emulator flow. */
startPath?: string
/** Preset selected on first render — 'viewer', 'data-write', or 'account-admin'. */
defaultPresetId?: string
}
function ScopedAutodeskSignIn({
nextPath,
title = 'Connect Autodesk',
titleAs: Title = 'h1',
description = 'Choose the access this workspace should request.',
startPath = `/api/auth/${apsProvider.id}`,
defaultPresetId,
}: ScopedAutodeskSignInProps) {
const [value, setValue] = useState<string[]>(
() =>
(defaultPresetId
? modelViewerPresets.find((preset) => preset.id === defaultPresetId)?.scopes
: undefined) ??
modelViewerPresets[0]?.scopes ??
[],
)
const selectedScopes = withRequiredScopes(modelViewerScopes, value)
const params = new URLSearchParams({ next: nextPath, scopes: selectedScopes.join(' ') })
const signInHref = `${startPath}?${params}`
return (
<Card data-slot="scoped-autodesk-sign-in" className="w-full max-w-2xl">
<CardHeader className="text-center">
<CardTitle className="text-2xl">
<Title>{title}</Title>
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<ScopePicker
scopes={modelViewerScopes}
value={value}
onChange={setValue}
presets={modelViewerPresets}
presetsLabel="Access level"
collapsibleScopes
/>
<ProviderSignInLink provider={apsProvider} href={signInHref}>
Continue with Autodesk
</ProviderSignInLink>
<p className="text-center text-muted-foreground text-xs">
Autodesk confirms new permissions the first time you grant them.
</p>
</CardContent>
</Card>
)
}
export { ScopedAutodeskSignIn }
import {
APS_PROVIDER_ID,
allowedSignInScopes,
appOrigin,
cookieSecurity,
getApsOAuth,
newState,
safeNext,
stateCookie,
} from '@/lib/acc-auth'
/** Install target: app/api/auth/[provider]/route.ts */
export async function GET(request: Request, ctx: { params: Promise<{ provider: string }> }) {
const { provider } = await ctx.params
if (provider !== APS_PROVIDER_ID) {
return new Response(`Unknown provider: ${provider}`, { status: 404 })
}
const url = new URL(request.url)
const origin = appOrigin(url.origin)
const scopes = allowedSignInScopes(url.searchParams.get('scopes'))
const oauthState = newState(safeNext(url.searchParams.get('next'), '/sign-in'), scopes)
const oauth = getApsOAuth(origin)
const authorizeUrl = oauth.authorizeUrl({
redirectUri: `${origin}/api/auth/callback/${APS_PROVIDER_ID}`,
scopes,
state: oauthState.state,
})
return new Response(null, {
status: 302,
headers: {
Location: authorizeUrl,
'Set-Cookie': stateCookie(oauthState, cookieSecurity(origin)),
},
})
}
import {
APS_PROVIDER_ID,
appOrigin,
clearStateCookie,
cookieSecurity,
getApsOAuth,
getVaultStore,
readStateCookie,
safeNext,
saveUserGrant,
sealSession,
sessionCookie,
userInfoUrl,
} from '@/lib/acc-auth'
/** Install target: app/api/auth/callback/[provider]/route.ts */
export async function GET(request: Request, ctx: { params: Promise<{ provider: string }> }) {
const { provider } = await ctx.params
if (provider !== APS_PROVIDER_ID) {
return new Response(`Unknown provider: ${provider}`, { status: 404 })
}
const url = new URL(request.url)
const origin = appOrigin(url.origin)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const stored = readStateCookie(request.headers.get('cookie'))
const secure = cookieSecurity(origin)
if (!code || !state || !stored || stored.state !== state) {
return new Response('Invalid OAuth state', {
status: 400,
headers: { 'Set-Cookie': clearStateCookie(secure) },
})
}
const oauth = getApsOAuth(origin)
const result = await oauth.exchangeCode({
code,
redirectUri: `${origin}/api/auth/callback/${APS_PROVIDER_ID}`,
})
const infoResponse = await fetch(userInfoUrl(origin), {
headers: { Authorization: `Bearer ${result.accessToken.token}` },
})
if (!infoResponse.ok) {
return new Response('Failed to load the user profile', { status: 502 })
}
const info = (await infoResponse.json()) as {
sub?: string
name?: string
email?: string
picture?: string
}
const userId = info.sub
if (!userId) {
return new Response('User profile has no subject id', { status: 502 })
}
const scopes = result.accessToken.scopes ? [...result.accessToken.scopes] : stored.scopes
const [session] = await Promise.all([
sealSession({
userId,
name: info.name,
email: info.email,
avatarUrl: info.picture,
scopes,
}),
result.refreshToken
? saveUserGrant(getVaultStore(), APS_PROVIDER_ID, userId, {
refreshToken: result.refreshToken,
scopes,
obtainedAt: Date.now(),
})
: undefined,
])
// The state cookie is client-side state: re-validate `next` on the return leg
// so an injected cookie can never turn a sign-in into an open redirect.
const headers = new Headers({ Location: safeNext(stored.next, '/sign-in') })
headers.append('Set-Cookie', sessionCookie(session, secure))
headers.append('Set-Cookie', clearStateCookie(secure))
return new Response(null, { status: 302, headers })
}
import {
APS_PROVIDER_ID,
appOrigin,
clearSessionCookie,
cookieSecurity,
deleteUserGrant,
getVaultStore,
openSession,
SESSION_COOKIE,
safeNext,
} from '@/lib/acc-auth'
const SESSION_COOKIE_PATTERN = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`)
/** Install target: app/api/auth/signout/route.ts */
export async function POST(request: Request) {
const url = new URL(request.url)
const origin = appOrigin(url.origin)
const requestOrigin = request.headers.get('origin')
const fetchSite = request.headers.get('sec-fetch-site')
if (
fetchSite === 'cross-site' ||
(requestOrigin !== null && new URL(requestOrigin).origin !== origin)
) {
return Response.json({ error: 'Cross-origin sign-out is not allowed.' }, { status: 403 })
}
const cookieHeader = request.headers.get('cookie')
const match = cookieHeader?.match(SESSION_COOKIE_PATTERN)
const session = await openSession(match?.[1])
if (session) {
await deleteUserGrant(getVaultStore(), APS_PROVIDER_ID, session.userId)
}
const next = safeNext(url.searchParams.get('next'), '/sign-in')
return new Response(null, {
status: 303,
headers: { Location: next, 'Set-Cookie': clearSessionCookie(cookieSecurity(origin)) },
})
}