{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "acc-sign-in", "title": "ACC Sign-In", "author": "MR ", "description": "Complete Autodesk (APS / ACC) sign-in flow on aec-auth: consent redirect, code exchange, vault-managed refresh, signed session, and a live connection panel.", "dependencies": [ "aec-auth" ], "registryDependencies": [ "@cantera/sign-in-card", "@cantera/connection-card", "@cantera/aps-oauth-preset", "@cantera/oauth-types" ], "files": [ { "path": "registry/blocks/acc-sign-in/page.tsx", "content": "import { TokenError } from 'aec-auth'\nimport { cookies, headers } from 'next/headers'\n\nimport { AccConnectionPanel } from '@/components/acc-connection-panel'\nimport { SignInCard } from '@/components/ui/sign-in-card'\nimport { APS_PROVIDER_ID, getTokenSource, openSession, SESSION_COOKIE } from '@/lib/acc-auth'\nimport { apsProvider } from '@/lib/aps-oauth-preset'\nimport type { OAuthConnection } from '@/lib/oauth-types'\n\n/**\n * The acc-sign-in block: sign in with Autodesk, then see the live connection —\n * account, token expiry, held scopes — with disconnect and reconnect.\n *\n * Reusable inner component: render from\n * any server page; the default export is a ready-made /sign-in page.\n */\nasync function requestOrigin(): Promise {\n const headerList = await headers()\n const host = headerList.get('x-forwarded-host') ?? headerList.get('host') ?? 'localhost:3000'\n const proto = headerList.get('x-forwarded-proto') ?? 'http'\n return `${proto}://${host}`\n}\n\nexport async function AccSignIn({\n nextPath = '/sign-in',\n headingLevel = 'h1',\n}: {\n nextPath?: string\n /** Heading level for the block's title. Drop to h2 when embedding under one. */\n headingLevel?: 'h1' | 'h2' | 'h3'\n}) {\n const Heading = headingLevel\n const cookieStore = await cookies()\n const session = await openSession(cookieStore.get(SESSION_COOKIE)?.value)\n const signInHref = `/api/auth/${APS_PROVIDER_ID}?next=${encodeURIComponent(nextPath)}`\n\n if (!session) {\n return (\n \n )\n }\n\n const account = { name: session.name, email: session.email, avatarUrl: session.avatarUrl }\n let connection: OAuthConnection\n try {\n const origin = await requestOrigin()\n const token = await getTokenSource(origin).getToken({\n provider: APS_PROVIDER_ID,\n subject: { type: 'user', id: session.userId },\n scopes: session.scopes,\n })\n connection = {\n provider: apsProvider,\n status: 'connected',\n account,\n scopes: token.scopes ? [...token.scopes] : session.scopes,\n expiresAt: token.expiresAt,\n }\n } catch (error) {\n connection = {\n provider: apsProvider,\n status:\n error instanceof TokenError && error.code === 'consent_required' ? 'expired' : 'error',\n account,\n scopes: session.scopes,\n error:\n error instanceof TokenError && error.code === 'consent_required'\n ? 'Grant lost — reconnect to continue.'\n : 'Could not refresh the token.',\n }\n }\n\n return (\n
\n \n Autodesk connection\n \n \n
\n )\n}\n\nexport default function SignInPage() {\n return (\n
\n \n
\n )\n}\n", "type": "registry:page", "target": "app/sign-in/page.tsx" }, { "path": "registry/blocks/acc-sign-in/components/acc-connection-panel.tsx", "content": "'use client'\n\nimport { useRouter } from 'next/navigation'\nimport { useState } from 'react'\n\nimport { ConnectionCard } from '@/components/ui/connection-card'\nimport type { OAuthConnection } from '@/lib/oauth-types'\n\ninterface AccConnectionPanelProps {\n connection: OAuthConnection\n /** POST target that clears the grant and session, e.g. \"/api/auth/signout?next=/sign-in\". */\n signOutHref: string\n /** GET target that restarts consent, e.g. \"/api/auth/aps?next=/sign-in\". */\n signInHref: string\n}\n\n/**\n * Client wrapper around ConnectionCard for the acc-sign-in block: disconnect\n * posts to the signout route, reconnect restarts the consent flow.\n */\nfunction AccConnectionPanel({ connection, signOutHref, signInHref }: AccConnectionPanelProps) {\n const router = useRouter()\n const [disconnecting, setDisconnecting] = useState(false)\n const [reconnecting, setReconnecting] = useState(false)\n\n async function disconnect() {\n setDisconnecting(true)\n try {\n await fetch(signOutHref, { method: 'POST', redirect: 'manual' })\n router.refresh()\n } finally {\n setDisconnecting(false)\n }\n }\n\n return (\n {\n setReconnecting(true)\n window.location.href = signInHref\n }}\n reconnectPending={reconnecting}\n />\n )\n}\n\nexport { AccConnectionPanel, type AccConnectionPanelProps }\n", "type": "registry:component", "target": "components/acc-connection-panel.tsx" }, { "path": "registry/blocks/acc-sign-in/lib/acc-auth.ts", "content": "import { APS_AUTH, TokenError, type TokenSource } from 'aec-auth'\nimport {\n apsOAuth,\n deleteUserGrant,\n memoryVaultStore,\n saveUserGrant,\n type VaultStore,\n vaultTokenSource,\n} from 'aec-auth/vault'\n\n/**\n * Server-side auth wiring for the acc-sign-in block, on aec-auth's vault:\n * OAuth endpoints, grant storage, and a signed session cookie.\n *\n * Environment:\n * - APS_CLIENT_ID / APS_CLIENT_SECRET — your APS app credentials.\n * - APS_AUTH_BASE_URL — optional auth origin override. Absolute\n * (\"http://localhost:4014\") or relative (\"/emulate/aps\", resolved against\n * the request origin) for the @emulators/aps emulator. Unset = real APS.\n * - SESSION_SECRET — HMAC key for the session cookie. Set it in production.\n *\n * The default vault store is in-memory: fine for demos and a single dev\n * server, wrong for production. Swap in a durable VaultStore (e.g.\n * `upstashVaultStore()` wrapped in `encryptedVaultStore`) — see the aec-auth\n * README.\n */\n\nexport const APS_PROVIDER_ID = 'aps'\n\n/** Scopes requested when the sign-in flow starts, unless overridden. */\nexport const DEFAULT_SIGN_IN_SCOPES = ['user-profile:read', 'data:read', 'viewables:read']\n\nconst globalStore = globalThis as { __accVaultStore?: VaultStore }\n\nexport function getVaultStore(): VaultStore {\n globalStore.__accVaultStore ??= memoryVaultStore()\n return globalStore.__accVaultStore\n}\n\nfunction resolveAuthBase(origin: string): string | undefined {\n const configured = process.env.APS_AUTH_BASE_URL\n if (!configured) return undefined\n return configured.startsWith('/') ? `${origin}${configured}` : configured\n}\n\nexport function getApsOAuth(origin: string) {\n const clientId = process.env.APS_CLIENT_ID\n if (!clientId) {\n throw new TokenError('not_configured', 'aps', 'APS_CLIENT_ID is not set')\n }\n return apsOAuth({\n clientId,\n clientSecret: process.env.APS_CLIENT_SECRET,\n baseUrl: resolveAuthBase(origin),\n })\n}\n\nexport function getTokenSource(origin: string): TokenSource {\n return vaultTokenSource({\n store: getVaultStore(),\n providers: { aps: getApsOAuth(origin) },\n })\n}\n\nexport function userInfoUrl(origin: string): string {\n const base = resolveAuthBase(origin)\n return base ? `${base}/userinfo` : APS_AUTH.userInfoUrl\n}\n\nexport { deleteUserGrant, saveUserGrant }\n\n// ---------------------------------------------------------------------------\n// Session cookie — HMAC-SHA256-signed JSON. No secrets inside, only identity.\n// ---------------------------------------------------------------------------\n\nexport interface AccSession {\n userId: string\n name?: string\n email?: string\n avatarUrl?: string\n scopes?: string[]\n}\n\nexport const SESSION_COOKIE = 'acc-session'\nconst STATE_COOKIE = 'acc-oauth-state'\n\nfunction sessionSecret(): string {\n const secret = process.env.SESSION_SECRET\n if (secret) return secret\n // A publicly known fallback is only tolerable where forged sessions do not\n // matter: local development, or a deployment that explicitly opts into demo\n // mode (ACC_AUTH_DEMO=1, e.g. an emulator-backed showcase). Everywhere else,\n // fail closed — a shared default key lets anyone mint a session for any user.\n if (process.env.NODE_ENV !== 'production' || process.env.ACC_AUTH_DEMO === '1') {\n return 'cantera-demo-insecure-secret'\n }\n throw new Error(\n 'SESSION_SECRET is not set. Generate one (e.g. `openssl rand -base64 32`) and set it in production.',\n )\n}\n\nconst encoder = new TextEncoder()\n\nfunction toBase64Url(bytes: Uint8Array): string {\n let binary = ''\n for (const byte of bytes) binary += String.fromCharCode(byte)\n return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')\n}\n\nfunction fromBase64Url(value: string): Uint8Array {\n const padded = value.replaceAll('-', '+').replaceAll('_', '/')\n return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0))\n}\n\nasync function hmac(payload: string): Promise {\n const key = await crypto.subtle.importKey(\n 'raw',\n encoder.encode(sessionSecret()),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n )\n const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(payload))\n return toBase64Url(new Uint8Array(signature))\n}\n\nexport async function sealSession(session: AccSession): Promise {\n const payload = toBase64Url(encoder.encode(JSON.stringify(session)))\n return `${payload}.${await hmac(payload)}`\n}\n\nexport async function openSession(cookieValue: string | undefined): Promise {\n if (!cookieValue) return null\n const [payload, signature] = cookieValue.split('.')\n if (!payload || !signature) return null\n if ((await hmac(payload)) !== signature) return null\n try {\n return JSON.parse(new TextDecoder().decode(fromBase64Url(payload))) as AccSession\n } catch {\n return null\n }\n}\n\n/** `Secure` for HTTPS requests; local plain-HTTP development stays usable. */\nexport function cookieSecurity(requestUrl: URL | string): string {\n const url = typeof requestUrl === 'string' ? new URL(requestUrl) : requestUrl\n return url.protocol === 'https:' ? '; Secure' : ''\n}\n\nexport function sessionCookie(\n value: string,\n secure: string,\n maxAgeSeconds = 60 * 60 * 24 * 14,\n): string {\n return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}${secure}`\n}\n\nexport function clearSessionCookie(secure: string): string {\n return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`\n}\n\n// ---------------------------------------------------------------------------\n// OAuth state cookie — CSRF token plus the post-sign-in return path.\n// ---------------------------------------------------------------------------\n\nexport interface OAuthState {\n state: string\n next: string\n /** Scopes requested at the start of the flow, kept for providers (and\n * emulators) whose token response omits the granted scope list. */\n scopes?: string[]\n}\n\nexport function newState(next: string, scopes?: string[]): OAuthState {\n const bytes = crypto.getRandomValues(new Uint8Array(24))\n return { state: toBase64Url(bytes), next, scopes }\n}\n\nexport function stateCookie(oauthState: OAuthState, secure: string): string {\n const value = toBase64Url(encoder.encode(JSON.stringify(oauthState)))\n return `${STATE_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600${secure}`\n}\n\nexport function clearStateCookie(secure: string): string {\n return `${STATE_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`\n}\n\nexport function readStateCookie(cookieHeader: string | null): OAuthState | null {\n const match = cookieHeader?.match(new RegExp(`(?:^|;\\\\s*)${STATE_COOKIE}=([^;]+)`))\n if (!match) return null\n try {\n return JSON.parse(new TextDecoder().decode(fromBase64Url(match[1]))) as OAuthState\n } catch {\n return null\n }\n}\n\n/** Only allow same-origin relative return paths. */\nexport function safeNext(next: string | null | undefined, fallback: string): string {\n // Same-origin relative paths only. `//` is protocol-relative, and browsers\n // normalize backslashes in a Location header (\"/\\\\evil.com\" -> \"//evil.com\"),\n // so both are external redirects in disguise.\n if (next?.startsWith('/') && !next.startsWith('//') && !next.includes('\\\\')) return next\n return fallback\n}\n", "type": "registry:lib", "target": "lib/acc-auth.ts" }, { "path": "registry/blocks/acc-sign-in/api/auth-start-route.ts", "content": "import {\n APS_PROVIDER_ID,\n cookieSecurity,\n DEFAULT_SIGN_IN_SCOPES,\n getApsOAuth,\n newState,\n safeNext,\n stateCookie,\n} from '@/lib/acc-auth'\n\n/**\n * Starts the 3-legged APS sign-in: builds the consent URL and redirects.\n * Install target: app/api/auth/[provider]/route.ts\n *\n * Query params:\n * - next — same-origin path to return to after sign-in (default /sign-in).\n * - scopes — space- or comma-separated scope override.\n */\nexport async function GET(request: Request, ctx: { params: Promise<{ provider: string }> }) {\n const { provider } = await ctx.params\n if (provider !== APS_PROVIDER_ID) {\n return new Response(`Unknown provider: ${provider}`, { status: 404 })\n }\n\n const url = new URL(request.url)\n const scopesParam = url.searchParams.get('scopes')\n const scopes = scopesParam ? scopesParam.split(/[\\s,]+/).filter(Boolean) : DEFAULT_SIGN_IN_SCOPES\n const oauthState = newState(safeNext(url.searchParams.get('next'), '/sign-in'), scopes)\n\n const oauth = getApsOAuth(url.origin)\n const authorizeUrl = oauth.authorizeUrl({\n redirectUri: `${url.origin}/api/auth/callback/${APS_PROVIDER_ID}`,\n scopes,\n state: oauthState.state,\n })\n\n return new Response(null, {\n status: 302,\n headers: {\n Location: authorizeUrl,\n 'Set-Cookie': stateCookie(oauthState, cookieSecurity(url)),\n },\n })\n}\n", "type": "registry:file", "target": "app/api/auth/[provider]/route.ts" }, { "path": "registry/blocks/acc-sign-in/api/auth-callback-route.ts", "content": "import {\n APS_PROVIDER_ID,\n clearStateCookie,\n cookieSecurity,\n getApsOAuth,\n getVaultStore,\n readStateCookie,\n saveUserGrant,\n sealSession,\n sessionCookie,\n userInfoUrl,\n} from '@/lib/acc-auth'\n\n/**\n * Completes the sign-in: verifies state, exchanges the code, hands the\n * refresh token to the vault (the single owner of refresh from here on),\n * and seals the session cookie.\n * Install target: app/api/auth/callback/[provider]/route.ts\n */\nexport async function GET(request: Request, ctx: { params: Promise<{ provider: string }> }) {\n const { provider } = await ctx.params\n if (provider !== APS_PROVIDER_ID) {\n return new Response(`Unknown provider: ${provider}`, { status: 404 })\n }\n\n const url = new URL(request.url)\n const code = url.searchParams.get('code')\n const state = url.searchParams.get('state')\n const stored = readStateCookie(request.headers.get('cookie'))\n\n const secure = cookieSecurity(url)\n if (!code || !state || !stored || stored.state !== state) {\n return new Response('Invalid OAuth state', {\n status: 400,\n headers: { 'Set-Cookie': clearStateCookie(secure) },\n })\n }\n\n const oauth = getApsOAuth(url.origin)\n const result = await oauth.exchangeCode({\n code,\n redirectUri: `${url.origin}/api/auth/callback/${APS_PROVIDER_ID}`,\n })\n\n const infoResponse = await fetch(userInfoUrl(url.origin), {\n headers: { Authorization: `Bearer ${result.accessToken.token}` },\n })\n if (!infoResponse.ok) {\n return new Response('Failed to load the user profile', { status: 502 })\n }\n const info = (await infoResponse.json()) as {\n sub?: string\n name?: string\n email?: string\n picture?: string\n }\n const userId = info.sub\n if (!userId) {\n return new Response('User profile has no subject id', { status: 502 })\n }\n\n // Providers (and emulators) may omit the granted scope list from the token\n // response; fall back to what the flow requested, kept in the state cookie.\n const scopes = result.accessToken.scopes ? [...result.accessToken.scopes] : stored.scopes\n if (result.refreshToken) {\n await saveUserGrant(getVaultStore(), APS_PROVIDER_ID, userId, {\n refreshToken: result.refreshToken,\n scopes,\n obtainedAt: Date.now(),\n })\n }\n\n const session = await sealSession({\n userId,\n name: info.name,\n email: info.email,\n avatarUrl: info.picture,\n scopes,\n })\n\n const headers = new Headers({ Location: stored.next })\n headers.append('Set-Cookie', sessionCookie(session, secure))\n headers.append('Set-Cookie', clearStateCookie(secure))\n return new Response(null, { status: 302, headers })\n}\n", "type": "registry:file", "target": "app/api/auth/callback/[provider]/route.ts" }, { "path": "registry/blocks/acc-sign-in/api/signout-route.ts", "content": "import {\n APS_PROVIDER_ID,\n clearSessionCookie,\n cookieSecurity,\n deleteUserGrant,\n getVaultStore,\n openSession,\n SESSION_COOKIE,\n safeNext,\n} from '@/lib/acc-auth'\n\n/**\n * Signs out: deletes the stored grant and clears the session cookie.\n * Install target: app/api/auth/signout/route.ts\n */\nexport async function POST(request: Request) {\n const cookieHeader = request.headers.get('cookie')\n const match = cookieHeader?.match(new RegExp(`(?:^|;\\\\s*)${SESSION_COOKIE}=([^;]+)`))\n const session = await openSession(match?.[1])\n if (session) {\n await deleteUserGrant(getVaultStore(), APS_PROVIDER_ID, session.userId)\n }\n\n const url = new URL(request.url)\n const next = safeNext(url.searchParams.get('next'), '/sign-in')\n return new Response(null, {\n status: 303,\n headers: { Location: next, 'Set-Cookie': clearSessionCookie(cookieSecurity(url)) },\n })\n}\n", "type": "registry:file", "target": "app/api/auth/signout/route.ts" } ], "envVars": { "APS_CLIENT_ID": "", "APS_CLIENT_SECRET": "", "SESSION_SECRET": "", "APS_AUTH_BASE_URL": "" }, "docs": "Installed: app/sign-in/page.tsx, the /api/auth/* route handlers, and lib/acc-auth.ts (aec-auth vault wiring).\n\nEnvironment (added to .env.local as empty keys — fill them in):\n- APS_CLIENT_ID / APS_CLIENT_SECRET — your APS app credentials.\n- SESSION_SECRET — HMAC key for the session cookie. Generate one with `openssl rand -base64 32`. In production the block fails closed without it: a shared default key would let anyone mint a session for any user.\n- APS_AUTH_BASE_URL — optional auth-origin override, absolute or relative (\"/emulate/aps\") for an embedded emulator. Leave it unset to talk to real APS.\n\nACC_AUTH_DEMO=1 is the escape hatch that allows the insecure fallback session secret in production. It exists for emulator-backed showcases only — never set it anywhere real accounts exist.\n\nThe default vault store is in-memory: correct for a demo, wrong for production. Swap in a durable VaultStore (see the aec-auth README) before you ship.", "categories": [ "authentication", "login", "autodesk" ], "type": "registry:block" }