diff --git a/apps/blog-next/app/api-token-posts/page.tsx b/apps/blog-next/app/api-token-posts/page.tsx new file mode 100644 index 00000000..8cb27935 --- /dev/null +++ b/apps/blog-next/app/api-token-posts/page.tsx @@ -0,0 +1,7 @@ +import { TokenPostsClient } from './token-posts-client' + +export const dynamic = 'force-dynamic' + +export default function ApiTokenPostsPage() { + return +} diff --git a/apps/blog-next/app/api-token-posts/token-posts-client.tsx b/apps/blog-next/app/api-token-posts/token-posts-client.tsx new file mode 100644 index 00000000..8277650d --- /dev/null +++ b/apps/blog-next/app/api-token-posts/token-posts-client.tsx @@ -0,0 +1,129 @@ +'use client' + +import { type FormEvent, useState } from 'react' + +type JsonResult = { + readonly status: number + readonly payload: unknown +} + +function isRecord(value: unknown): value is Readonly> { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function getStringField(value: unknown, field: string): string { + if (!isRecord(value)) { + return '' + } + + const fieldValue = value[field] + return typeof fieldValue === 'string' ? fieldValue : '' +} + +async function readJson(response: Response): Promise { + try { + return await response.json() + } catch { + return { + ok: false, + message: 'Response was not valid JSON.', + } + } +} + +export function TokenPostsClient() { + const [tokenResult, setTokenResult] = useState(null) + const [postsResult, setPostsResult] = useState(null) + const [creatingToken, setCreatingToken] = useState(false) + const [fetchingPosts, setFetchingPosts] = useState(false) + const generatedToken = getStringField(tokenResult?.payload, 'token') + + async function createToken(event: FormEvent) { + event.preventDefault() + setCreatingToken(true) + setPostsResult(null) + + try { + const response = await fetch('/api/v1/tokens', { + method: 'POST', + body: new FormData(event.currentTarget), + }) + + setTokenResult({ + status: response.status, + payload: await readJson(response), + }) + } finally { + setCreatingToken(false) + } + } + + async function fetchPosts(event: FormEvent) { + event.preventDefault() + setFetchingPosts(true) + + const formData = new FormData(event.currentTarget) + const token = String(formData.get('token') ?? '').trim() + + try { + const response = await fetch('/api/v1/posts', { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }) + + setPostsResult({ + status: response.status, + payload: await readJson(response), + }) + } finally { + setFetchingPosts(false) + } + } + + return ( + + + API token posts + Generate a bearer token from credentials, then use it to fetch protected posts. + + + + Create token + + Email + + + + Password + + + {creatingToken ? 'Creating...' : 'Create token'} + + + {tokenResult ? ( + + Token response ({tokenResult.status}) + {generatedToken ? ( + + ) : null} + {JSON.stringify(tokenResult.payload, null, 2)} + + ) : null} + + + Fetch posts + + Bearer token + + + {fetchingPosts ? 'Fetching...' : 'Fetch posts'} + + + {postsResult ? ( + + Posts response ({postsResult.status}) + {JSON.stringify(postsResult.payload, null, 2)} + + ) : null} + + ) +} diff --git a/apps/blog-next/app/api/v1/posts/route.ts b/apps/blog-next/app/api/v1/posts/route.ts new file mode 100644 index 00000000..da48f012 --- /dev/null +++ b/apps/blog-next/app/api/v1/posts/route.ts @@ -0,0 +1,34 @@ +import auth from '@holo-js/auth' + +import Post from '@/server/models/Post' + +export async function GET() { + const currentUser = await auth.guard('api').user() + + if (!currentUser) { + return Response.json({ + ok: false, + message: 'Unauthenticated.', + }, { status: 401 }) + } + + const userId = currentUser.id + + if (!currentUser.can('posts.read')) { + return Response.json({ + ok: false, + message: 'Forbidden.', + }, { status: 403 }) + } + + const posts = await Post + .with('category', 'tags') + .where('user_id', userId) + .orderBy('published_at', 'desc') + .get() + + return Response.json({ + ok: true, + posts, + }) +} diff --git a/apps/blog-next/app/api/v1/tokens/route.ts b/apps/blog-next/app/api/v1/tokens/route.ts new file mode 100644 index 00000000..dc617c11 --- /dev/null +++ b/apps/blog-next/app/api/v1/tokens/route.ts @@ -0,0 +1,40 @@ +import auth from '@holo-js/auth' +import { validate } from '@holo-js/forms' + +import { loginForm } from '@/lib/schemas/auth' + +export async function POST(request: Request) { + const submission = await validate(request, loginForm, { + throttle: 'login', + }) + + if (!submission.valid) { + const failure = submission.fail() + return Response.json(failure, { + status: failure.status, + }) + } + + const { data: token, error } = await auth.guard('api').login({ + ...submission.data, + abilities: ['posts.read'], + }) + + if (error) { + return Response.json({ + ok: false, + message: 'Invalid credentials.', + }, { status: 401 }) + } + + return Response.json({ + ok: true, + token: token.plainTextToken, + tokenId: token.id, + abilities: token.abilities, + }, { + headers: { + 'Cache-Control': 'no-store', + }, + }) +} diff --git a/apps/blog-next/app/layout.tsx b/apps/blog-next/app/layout.tsx index 92b27e49..0e72de8f 100644 --- a/apps/blog-next/app/layout.tsx +++ b/apps/blog-next/app/layout.tsx @@ -24,6 +24,7 @@ export default async function RootLayout({ children }: { children: ReactNode }) blog-next Posts + API Token Admin Super Admin diff --git a/apps/blog-next/config/auth.ts b/apps/blog-next/config/auth.ts index d460b04a..97adad8a 100644 --- a/apps/blog-next/config/auth.ts +++ b/apps/blog-next/config/auth.ts @@ -14,6 +14,10 @@ export default defineAuthConfig({ driver: 'session', provider: 'admins', }, + api: { + driver: 'token', + provider: 'users', + }, }, providers: { users: { diff --git a/apps/blog-next/server/db/seeders/BlogSeeder.ts b/apps/blog-next/server/db/seeders/BlogSeeder.ts index 2c075458..cc19a844 100644 --- a/apps/blog-next/server/db/seeders/BlogSeeder.ts +++ b/apps/blog-next/server/db/seeders/BlogSeeder.ts @@ -11,7 +11,7 @@ export default defineSeeder({ name: 'BlogSeeder', async run() { const timestamp = new Date('2026-04-26T09:00:00.000Z') - const userPassword = await hashPassword('secret') + const userPassword = await hashPassword('secret-secret') const adminPassword = await hashPassword('admin-secret') const author = await User.unguarded(() => diff --git a/apps/blog-next/server/lib/blog.ts b/apps/blog-next/server/lib/blog.ts index 84318438..fde32882 100644 --- a/apps/blog-next/server/lib/blog.ts +++ b/apps/blog-next/server/lib/blog.ts @@ -1,3 +1,5 @@ +import { hashPassword } from '@holo-js/auth' + import Post from '../models/Post' import User from '../models/User' import Category from '../models/Category' @@ -13,11 +15,12 @@ async function ensureAuthorId(): Promise { return existing.id } + const password = await hashPassword('secret-secret') const user = await User.unguarded(() => User.create({ name: 'Holo Editor', email: 'editor@example.com', - password: 'secret', + password, avatar: null, email_verified_at: now(), }), diff --git a/apps/blog-next/tests/run.mjs b/apps/blog-next/tests/run.mjs index 6e144b65..dbeb5cfb 100644 --- a/apps/blog-next/tests/run.mjs +++ b/apps/blog-next/tests/run.mjs @@ -8,6 +8,7 @@ import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { DEFAULT_SESSION_COOKIE_NAME } from '@holo-js/config' import { assertExampleAppAuthFlow } from '../../../tests/example-app-auth-flow.mjs' +import { assertExampleAppTokenAuthFlow } from '../../../tests/example-app-token-auth-flow.mjs' const cwd = process.cwd() const configPath = join(cwd, 'config/app.ts') @@ -252,6 +253,10 @@ try { appName: 'blog-next', sessionCookieName: DEFAULT_SESSION_COOKIE_NAME, }) + await assertExampleAppTokenAuthFlow({ + baseUrl: `http://localhost:${port}`, + expectedTitle: 'Shipping a Real Holo Blog on Next', + }) await writeFile(configPath, originalConfig.replace("name: env('APP_NAME', 'blog-next')", "name: env('APP_NAME', 'blog-next-updated')")) const updated = await waitForJson(healthUrl, payload => payload.app === 'blog-next-updated') diff --git a/apps/blog-nuxt/app/app.vue b/apps/blog-nuxt/app/app.vue index 35bfc865..5551a089 100644 --- a/apps/blog-nuxt/app/app.vue +++ b/apps/blog-nuxt/app/app.vue @@ -20,6 +20,7 @@ async function logout() { blog-nuxt Posts + API Token Admin Super Admin diff --git a/apps/blog-nuxt/app/pages/api-token-posts.vue b/apps/blog-nuxt/app/pages/api-token-posts.vue new file mode 100644 index 00000000..9a448173 --- /dev/null +++ b/apps/blog-nuxt/app/pages/api-token-posts.vue @@ -0,0 +1,170 @@ + + + + + + API token posts + Generate a bearer token from credentials, then use it to fetch protected posts. + + + + Create token + + Email + + + + Password + + + + {{ creatingToken ? 'Creating...' : 'Create token' }} + + + + + Token response ({{ tokenResult.status }}) + + {{ JSON.stringify(tokenResult.payload, null, 2) }} + + + + Fetch posts + + Bearer token + + + + {{ fetchingPosts ? 'Fetching...' : 'Fetch posts' }} + + + + + Posts response ({{ postsResult.status }}) + {{ JSON.stringify(postsResult.payload, null, 2) }} + + + + + diff --git a/apps/blog-nuxt/config/auth.ts b/apps/blog-nuxt/config/auth.ts index d460b04a..97adad8a 100644 --- a/apps/blog-nuxt/config/auth.ts +++ b/apps/blog-nuxt/config/auth.ts @@ -14,6 +14,10 @@ export default defineAuthConfig({ driver: 'session', provider: 'admins', }, + api: { + driver: 'token', + provider: 'users', + }, }, providers: { users: { diff --git a/apps/blog-nuxt/server/api/v1/posts.get.ts b/apps/blog-nuxt/server/api/v1/posts.get.ts new file mode 100644 index 00000000..40a0e5ed --- /dev/null +++ b/apps/blog-nuxt/server/api/v1/posts.get.ts @@ -0,0 +1,38 @@ +import auth from '@holo-js/auth' + +import Post from '../../models/Post' + +export default defineEventHandler(async (event) => { + const currentUser = await auth.guard('api').user() + + if (!currentUser) { + setResponseStatus(event, 401) + + return { + ok: false, + message: 'Unauthenticated.', + } + } + + const userId = currentUser.id + + if (!currentUser.can('posts.read')) { + setResponseStatus(event, 403) + + return { + ok: false, + message: 'Forbidden.', + } + } + + const posts = await Post + .with('category', 'tags') + .where('user_id', userId) + .orderBy('published_at', 'desc') + .get() + + return { + ok: true, + posts, + } +}) diff --git a/apps/blog-nuxt/server/api/v1/tokens.post.ts b/apps/blog-nuxt/server/api/v1/tokens.post.ts new file mode 100644 index 00000000..28efb0dc --- /dev/null +++ b/apps/blog-nuxt/server/api/v1/tokens.post.ts @@ -0,0 +1,39 @@ +import auth from '@holo-js/auth' +import { validate } from '@holo-js/forms' + +import { loginForm } from '#shared/schemas/auth' + +export default defineEventHandler(async (event) => { + const submission = await validate(event, loginForm, { + throttle: 'login', + }) + + if (!submission.valid) { + const failure = submission.fail() + setResponseStatus(event, failure.status) + return failure + } + + const { data: token, error } = await auth.guard('api').login({ + ...submission.data, + abilities: ['posts.read'], + }) + + if (error) { + setResponseStatus(event, 401) + + return { + ok: false, + message: 'Invalid credentials.', + } + } + + setResponseHeader(event, 'Cache-Control', 'no-store') + + return { + ok: true, + token: token.plainTextToken, + tokenId: token.id, + abilities: token.abilities, + } +}) diff --git a/apps/blog-nuxt/server/db/seeders/BlogSeeder.ts b/apps/blog-nuxt/server/db/seeders/BlogSeeder.ts index 74760b46..23be698e 100644 --- a/apps/blog-nuxt/server/db/seeders/BlogSeeder.ts +++ b/apps/blog-nuxt/server/db/seeders/BlogSeeder.ts @@ -11,7 +11,7 @@ export default defineSeeder({ name: 'BlogSeeder', async run() { const timestamp = new Date('2026-04-26T09:00:00.000Z') - const userPassword = await hashPassword('secret') + const userPassword = await hashPassword('secret-secret') const adminPassword = await hashPassword('admin-secret') const author = await User.unguarded(() => User.create({ diff --git a/apps/blog-nuxt/server/lib/blog.ts b/apps/blog-nuxt/server/lib/blog.ts index 96e9c8c8..57d7ed0f 100644 --- a/apps/blog-nuxt/server/lib/blog.ts +++ b/apps/blog-nuxt/server/lib/blog.ts @@ -1,3 +1,5 @@ +import { hashPassword } from '@holo-js/auth' + import Post from '../models/Post' import User from '../models/User' import Category from '../models/Category' @@ -13,10 +15,11 @@ async function ensureAuthorId(): Promise { return existing.id } + const password = await hashPassword('secret-secret') const user = await User.unguarded(() => User.create({ name: 'Holo Editor', email: 'editor@example.com', - password: 'secret', + password, avatar: null, email_verified_at: now(), })) diff --git a/apps/blog-nuxt/tests/run.mjs b/apps/blog-nuxt/tests/run.mjs index ee3117b4..d4cbc6bb 100644 --- a/apps/blog-nuxt/tests/run.mjs +++ b/apps/blog-nuxt/tests/run.mjs @@ -8,6 +8,7 @@ import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { DEFAULT_SESSION_COOKIE_NAME } from '@holo-js/config' import { assertExampleAppAuthFlow } from '../../../tests/example-app-auth-flow.mjs' +import { assertExampleAppTokenAuthFlow } from '../../../tests/example-app-token-auth-flow.mjs' const cwd = process.cwd() const configPath = join(cwd, 'config/app.ts') @@ -250,6 +251,10 @@ try { appName: 'blog-nuxt', sessionCookieName: DEFAULT_SESSION_COOKIE_NAME, }) + await assertExampleAppTokenAuthFlow({ + baseUrl: `http://localhost:${port}`, + expectedTitle: 'Shipping a Real Holo Blog on Nuxt', + }) await writeFile(configPath, originalConfig.replace("name: env('APP_NAME', 'blog-nuxt')", "name: env('APP_NAME', 'blog-nuxt-updated')")) const updated = await waitForJson(healthUrl, payload => payload.app === 'blog-nuxt-updated') diff --git a/apps/blog-sveltekit/config/auth.ts b/apps/blog-sveltekit/config/auth.ts index d460b04a..97adad8a 100644 --- a/apps/blog-sveltekit/config/auth.ts +++ b/apps/blog-sveltekit/config/auth.ts @@ -14,6 +14,10 @@ export default defineAuthConfig({ driver: 'session', provider: 'admins', }, + api: { + driver: 'token', + provider: 'users', + }, }, providers: { users: { diff --git a/apps/blog-sveltekit/server/db/seeders/BlogSeeder.ts b/apps/blog-sveltekit/server/db/seeders/BlogSeeder.ts index 336c5488..a66b1910 100644 --- a/apps/blog-sveltekit/server/db/seeders/BlogSeeder.ts +++ b/apps/blog-sveltekit/server/db/seeders/BlogSeeder.ts @@ -11,7 +11,7 @@ export default defineSeeder({ name: 'BlogSeeder', async run() { const timestamp = new Date('2026-04-26T09:00:00.000Z') - const userPassword = await hashPassword('secret') + const userPassword = await hashPassword('secret-secret') const adminPassword = await hashPassword('admin-secret') const author = await User.unguarded(() => User.create({ diff --git a/apps/blog-sveltekit/src/lib/server/blog.ts b/apps/blog-sveltekit/src/lib/server/blog.ts index 13d68805..0de63153 100644 --- a/apps/blog-sveltekit/src/lib/server/blog.ts +++ b/apps/blog-sveltekit/src/lib/server/blog.ts @@ -1,3 +1,5 @@ +import { hashPassword } from '@holo-js/auth' + import Post from '../../../server/models/Post' import User from '../../../server/models/User' import Category from '../../../server/models/Category' @@ -13,10 +15,11 @@ async function ensureAuthorId(): Promise { return existing.id } + const password = await hashPassword('secret-secret') const user = await User.unguarded(() => User.create({ name: 'Holo Editor', email: 'editor@example.com', - password: 'secret', + password, avatar: null, email_verified_at: now(), })) diff --git a/apps/blog-sveltekit/src/routes/+layout.svelte b/apps/blog-sveltekit/src/routes/+layout.svelte index 0e6c4585..200887d7 100644 --- a/apps/blog-sveltekit/src/routes/+layout.svelte +++ b/apps/blog-sveltekit/src/routes/+layout.svelte @@ -42,6 +42,7 @@ blog-sveltekit Posts + API Token Admin Super Admin {#if auth.authenticated} diff --git a/apps/blog-sveltekit/src/routes/api-token-posts/+page.svelte b/apps/blog-sveltekit/src/routes/api-token-posts/+page.svelte new file mode 100644 index 00000000..b30132f2 --- /dev/null +++ b/apps/blog-sveltekit/src/routes/api-token-posts/+page.svelte @@ -0,0 +1,170 @@ + + + + + API token posts + Generate a bearer token from credentials, then use it to fetch protected posts. + + + + Create token + + Email + + + + Password + + + {creatingToken ? 'Creating...' : 'Create token'} + + + {#if tokenResult} + + Token response ({tokenResult.status}) + {#if generatedToken} + + {/if} + {JSON.stringify(tokenResult.payload, null, 2)} + + {/if} + + + Fetch posts + + Bearer token + + + {fetchingPosts ? 'Fetching...' : 'Fetch posts'} + + + {#if postsResult} + + Posts response ({postsResult.status}) + {JSON.stringify(postsResult.payload, null, 2)} + + {/if} + + + diff --git a/apps/blog-sveltekit/src/routes/api/v1/posts/+server.ts b/apps/blog-sveltekit/src/routes/api/v1/posts/+server.ts new file mode 100644 index 00000000..82a543f0 --- /dev/null +++ b/apps/blog-sveltekit/src/routes/api/v1/posts/+server.ts @@ -0,0 +1,35 @@ +import { json } from '@sveltejs/kit' +import auth from '@holo-js/auth' + +import Post from '../../../../../server/models/Post' + +export async function GET() { + const currentUser = await auth.guard('api').user() + + if (!currentUser) { + return json({ + ok: false, + message: 'Unauthenticated.', + }, { status: 401 }) + } + + const userId = currentUser.id + + if (!currentUser.can('posts.read')) { + return json({ + ok: false, + message: 'Forbidden.', + }, { status: 403 }) + } + + const posts = await Post + .with('category', 'tags') + .where('user_id', userId) + .orderBy('published_at', 'desc') + .get() + + return json({ + ok: true, + posts, + }) +} diff --git a/apps/blog-sveltekit/src/routes/api/v1/tokens/+server.ts b/apps/blog-sveltekit/src/routes/api/v1/tokens/+server.ts new file mode 100644 index 00000000..94fb1b13 --- /dev/null +++ b/apps/blog-sveltekit/src/routes/api/v1/tokens/+server.ts @@ -0,0 +1,41 @@ +import { json } from '@sveltejs/kit' +import auth from '@holo-js/auth' +import { validate } from '@holo-js/forms' + +import { loginForm } from '$lib/schemas/auth' + +export async function POST({ request }: { request: Request }) { + const submission = await validate(request, loginForm, { + throttle: 'login', + }) + + if (!submission.valid) { + const failure = submission.fail() + return json(failure, { + status: failure.status, + }) + } + + const { data: token, error } = await auth.guard('api').login({ + ...submission.data, + abilities: ['posts.read'], + }) + + if (error) { + return json({ + ok: false, + message: 'Invalid credentials.', + }, { status: 401 }) + } + + return json({ + ok: true, + token: token.plainTextToken, + tokenId: token.id, + abilities: token.abilities, + }, { + headers: { + 'Cache-Control': 'no-store', + }, + }) +} diff --git a/apps/blog-sveltekit/tests/run.mjs b/apps/blog-sveltekit/tests/run.mjs index 2eca0342..1515dd8b 100644 --- a/apps/blog-sveltekit/tests/run.mjs +++ b/apps/blog-sveltekit/tests/run.mjs @@ -8,6 +8,7 @@ import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { DEFAULT_SESSION_COOKIE_NAME } from '@holo-js/config' import { assertExampleAppAuthFlow } from '../../../tests/example-app-auth-flow.mjs' +import { assertExampleAppTokenAuthFlow } from '../../../tests/example-app-token-auth-flow.mjs' const cwd = process.cwd() const configPath = join(cwd, 'config/app.ts') @@ -307,6 +308,10 @@ try { appName: 'blog-sveltekit', sessionCookieName: DEFAULT_SESSION_COOKIE_NAME, }) + await assertExampleAppTokenAuthFlow({ + baseUrl: devUrl, + expectedTitle: 'Shipping a Real Holo Blog on SvelteKit', + }) await writeFile(configPath, originalConfig.replace("name: env('APP_NAME', 'blog-sveltekit')", "name: env('APP_NAME', 'blog-sveltekit-updated')")) await new Promise(resolve => setTimeout(resolve, 3000)) diff --git a/apps/docs/docs/auth/guards-and-providers.md b/apps/docs/docs/auth/guards-and-providers.md index e5e47a62..a1fa6192 100644 --- a/apps/docs/docs/auth/guards-and-providers.md +++ b/apps/docs/docs/auth/guards-and-providers.md @@ -101,6 +101,20 @@ await auth.guard('admin').user() await auth.guard('api').currentAccessToken() ``` +On a token guard, `login()` and `register()` return personal access token results instead of session results: + +```ts +const { data: token, error } = await auth.guard('api').login({ + email: 'ava@example.com', + password: 'secret-secret', + abilities: ['orders.read'], +}) + +if (!error) { + token.plainTextToken +} +``` + ## Default Guard Named exports use the configured default guard: diff --git a/apps/docs/docs/auth/personal-access-tokens.md b/apps/docs/docs/auth/personal-access-tokens.md index 08af6e33..1fd9ed7e 100644 --- a/apps/docs/docs/auth/personal-access-tokens.md +++ b/apps/docs/docs/auth/personal-access-tokens.md @@ -17,7 +17,133 @@ guards: { Tokens are created in plain text once, hashed at rest, and validated on each incoming request. -## Creating Tokens +## Token Login + +Use the token guard's normal `login()` method when a user submits credentials and the response should be a bearer token +instead of a session cookie: + +```ts +import auth from '@holo-js/auth' + +const { data: token, error } = await auth.guard('api').login({ + email: 'ava@example.com', + password: 'secret-secret', + abilities: ['orders.read'], +}) + +if (error) { + return Response.json({ + ok: false, + message: 'Invalid credentials.', + }, { status: 401 }) +} + +return Response.json({ + ok: true, + token: token.plainTextToken, + tokenId: token.id, + abilities: token.abilities, +}, { + headers: { + 'Cache-Control': 'no-store', + }, +}) +``` + +For token guards, `auth.guard('api').login(...)` verifies the credentials, creates a personal access token for the +authenticated user, and returns the token result. + +## Token Registration + +Use `register()` on a token guard when the registration response should immediately return a bearer token: + +```ts +import auth from '@holo-js/auth' + +const { data: token, error } = await auth.guard('api').register({ + name: 'Ava', + email: 'ava@example.com', + password: 'secret-secret', + passwordConfirmation: 'secret-secret', + abilities: ['orders.read'], +}) + +if (error) { + return Response.json(error, { status: error.status }) +} + +return Response.json({ + ok: true, + token: token.plainTextToken, + tokenId: token.id, + abilities: token.abilities, +}, { + headers: { + 'Cache-Control': 'no-store', + }, +}) +``` + +For session guards, `login()` and `register()` still return session results. For token guards, they return personal +access token results. TypeScript infers the guard driver from `config/auth.ts` through the generated `holo prepare` +types, so `auth.guard('api')` is token-backed when the `api` guard uses `driver: 'token'`. + +## Sending Tokens On Requests + +Send the token on protected API requests with the standard `Authorization` header: + +```ts +await fetch('/api/v1/orders', { + headers: { + Authorization: `Bearer ${token}`, + }, +}) +``` + +Framework adapters read the bearer token from the request and make it available to the selected token guard. Server +routes can then use the normal guard APIs: + +```ts +import auth from '@holo-js/auth' + +export async function GET() { + const currentUser = await auth.guard('api').user() + + if (!currentUser) { + return Response.json({ ok: false, message: 'Unauthenticated.' }, { status: 401 }) + } + + if (!currentUser.can('orders.read')) { + return Response.json({ ok: false, message: 'Forbidden.' }, { status: 403 }) + } + + const token = await auth.guard('api').currentAccessToken() + + return Response.json({ + ok: true, + userId: currentUser.id, + abilities: token?.abilities ?? [], + }) +} +``` + +The ability check should match the abilities you issue during token creation. In the login example above, the token gets +`orders.read`, so routes that expose order data should call `currentUser.can('orders.read')` before returning it. The +current token also exposes `token.can('orders.read')` when the route needs to inspect the token directly. A token with +`*` passes individual ability checks. + +`check()` follows the same guard context: + +```ts +if (!await auth.guard('api').check()) { + return Response.json({ ok: false, message: 'Unauthenticated.' }, { status: 401 }) +} +``` + +## Manual Token Creation + +Use the lower-level `tokens.create(...)` API when the user is already authenticated or trusted and your application +needs to issue a token manually, such as from an account settings screen. ```ts import { tokens } from '@holo-js/auth' @@ -35,12 +161,7 @@ The result contains: Show the plain text token to the user immediately after creation. The unhashed secret should be treated as write-only. -## Sending Tokens On Requests - -The application should pass the token as a bearer token or another transport of its choice, then assign it to the -token guard context before using the auth runtime for that request. - -## Authenticating Tokens +## Manual Token Authentication ```ts import { tokens } from '@holo-js/auth' @@ -50,6 +171,9 @@ const actor = await tokens.authenticate(created.plainTextToken) The runtime validates the token id and secret, updates `lastUsedAt`, and resolves the local user model. +Most framework routes should use `auth.guard('api').user()` or `auth.guard('api').check()` instead. Use +`tokens.authenticate(...)` when you are outside a Holo request context or implementing a custom token transport. + ## Token Abilities ```ts diff --git a/packages/adapter-nuxt/src/module.ts b/packages/adapter-nuxt/src/module.ts index 9dc75687..79d635e5 100644 --- a/packages/adapter-nuxt/src/module.ts +++ b/packages/adapter-nuxt/src/module.ts @@ -346,6 +346,7 @@ export default defineNuxtModule({ const opts = nuxt.options as unknown as NuxtOptionsWithNitro const rootDir = opts.rootDir ?? opts.srcDir ?? process.cwd() const sourceDir = opts.srcDir ?? rootDir + const authTypesPath = resolve(rootDir, '.holo-js/generated/auth.d.ts') const modelRegistryTypesPath = resolve(rootDir, '.holo-js/generated/model-registry.d.ts') addViteOptimizeDeps(opts, resolveClientOptimizeDeps(rootDir)) const loaded = await loadConfigDirectory(rootDir, { @@ -459,9 +460,11 @@ export default defineNuxtModule({ if (!opts._holoTypesRegistered) { opts._holoTypesRegistered = true + await ensureModelRegistryTypesPlaceholder(authTypesPath) await ensureModelRegistryTypesPlaceholder(modelRegistryTypesPath) nuxt.hook('prepare:types', ({ references }) => { references.push({ types: '@holo-js/adapter-nuxt' }) + references.push({ path: authTypesPath }) references.push({ path: modelRegistryTypesPath }) }) } diff --git a/packages/adapter-nuxt/tests/module.test.ts b/packages/adapter-nuxt/tests/module.test.ts index 200cf67b..b90d0e07 100644 --- a/packages/adapter-nuxt/tests/module.test.ts +++ b/packages/adapter-nuxt/tests/module.test.ts @@ -349,7 +349,11 @@ export default defineDatabaseConfig({ const references: Array> = [] prepareTypes?.({ references }) expect(references).toContainEqual({ types: '@holo-js/adapter-nuxt' }) + expect(references).toContainEqual({ path: resolve(root, '.holo-js/generated/auth.d.ts') }) expect(references).toContainEqual({ path: resolve(root, '.holo-js/generated/model-registry.d.ts') }) + expect(await readFile(resolve(root, '.holo-js/generated/auth.d.ts'), 'utf8')).toBe( + '// Generated by holo prepare. Do not edit.\n\nexport {}\n', + ) expect(await readFile(resolve(root, '.holo-js/generated/model-registry.d.ts'), 'utf8')).toBe( '// Generated by holo prepare. Do not edit.\n\nexport {}\n', ) diff --git a/packages/adapter-nuxt/tests/setup.test.ts b/packages/adapter-nuxt/tests/setup.test.ts index f2333e6c..baaa3cfa 100644 --- a/packages/adapter-nuxt/tests/setup.test.ts +++ b/packages/adapter-nuxt/tests/setup.test.ts @@ -517,6 +517,7 @@ export default defineStorageConfig({ prepareTypesHook?.({ references }) expect(references).toEqual([ { types: '@holo-js/adapter-nuxt' }, + { path: join(root, '.holo-js/generated/auth.d.ts') }, { path: join(root, '.holo-js/generated/model-registry.d.ts') }, ]) }, 30000) diff --git a/packages/auth-clerk/src/contracts.ts b/packages/auth-clerk/src/contracts.ts index 50f34613..281397f8 100644 --- a/packages/auth-clerk/src/contracts.ts +++ b/packages/auth-clerk/src/contracts.ts @@ -1,4 +1,4 @@ -import type { AuthEstablishedSession, AuthLogoutResult, AuthUserLike } from '@holo-js/auth' +import type { AuthenticatedAuthUser, AuthEstablishedSession, AuthLogoutResult } from '@holo-js/auth' import type { AuthClerkProviderConfig } from '@holo-js/config' export interface ClerkEmailAddress { @@ -55,7 +55,7 @@ export type ClerkCompleteAuthResult readonly identity: HostedIdentityRecord readonly session: ClerkVerifiedSession readonly authSession?: AuthEstablishedSession @@ -152,12 +152,17 @@ export interface HostedIdentityStore { export type ClerkSyncStatus = 'created' | 'updated' | 'linked' | 'relinked' +export type ClerkAuthenticatedUser> = Readonly>> = + AuthenticatedAuthUser & TUser & { + readonly id: string | number + } + export interface ClerkAuthenticationResult> = Readonly>> { readonly provider: string readonly guard: string readonly authProvider: string readonly status: ClerkSyncStatus - readonly user: AuthUserLike & TUser + readonly user: ClerkAuthenticatedUser readonly identity: HostedIdentityRecord readonly session: ClerkVerifiedSession readonly authSession?: AuthEstablishedSession diff --git a/packages/auth-clerk/src/index.ts b/packages/auth-clerk/src/index.ts index bc1d01a7..39332514 100644 --- a/packages/auth-clerk/src/index.ts +++ b/packages/auth-clerk/src/index.ts @@ -1,5 +1,5 @@ import { authRuntimeInternals, getAuthRuntime } from '@holo-js/auth' -import type { AuthEstablishedSession, AuthUserLike } from '@holo-js/auth' +import type { AuthenticatedAuthUser, AuthEstablishedSession, AuthUserLike } from '@holo-js/auth' import { parseCookieHeader } from '@holo-js/session' import type { AuthClerkProviderConfig, NormalizedAuthClerkProviderConfig } from '@holo-js/config' export { @@ -8,6 +8,7 @@ export { export type { ClerkAuthBindings, ClerkAuthFacade, + ClerkAuthenticatedUser, ClerkAuthenticationResult, ClerkCompleteAuthResult, ClerkEmailAddress, @@ -68,7 +69,7 @@ type CompleteClerkAuthOptions TUserAttributes } -type SerializedClerkAuthUser = AuthUserLike & { +type SerializedClerkAuthUser = AuthenticatedAuthUser & { readonly id: string | number } @@ -693,7 +694,7 @@ function serializeLocalUser, providerName: string, -): AuthUserLike & TUserAttributes { +): SerializedClerkAuthUser & TUserAttributes { const id = adapter.getId(user) const serialized = adapter.serialize ? adapter.serialize(user) @@ -708,8 +709,13 @@ function serializeLocalUser false, + enumerable: false, + configurable: true, + }) - return Object.freeze(result) as AuthUserLike & TUserAttributes + return Object.freeze(result) as SerializedClerkAuthUser & TUserAttributes } function resolvePrimaryEmail(profile: ClerkUserProfile): { email?: string, emailVerified: boolean } { diff --git a/packages/auth-workos/src/contracts.ts b/packages/auth-workos/src/contracts.ts index 5f2f30dc..fee941b3 100644 --- a/packages/auth-workos/src/contracts.ts +++ b/packages/auth-workos/src/contracts.ts @@ -1,4 +1,4 @@ -import type { AuthEstablishedSession, AuthLogoutResult, AuthUserLike } from '@holo-js/auth' +import type { AuthenticatedAuthUser, AuthEstablishedSession, AuthLogoutResult, AuthUserLike } from '@holo-js/auth' import type { NormalizedAuthWorkosProviderConfig } from '@holo-js/config' export type WorkosJsonValue = @@ -38,6 +38,11 @@ export interface WorkosLogoutSession { readonly sessionId: string } +export type WorkosAuthenticatedUser = + AuthenticatedAuthUser & TUser & { + readonly id: string | number + } + export type WorkosCompleteAuthResult = | Readonly<{ readonly ok: true @@ -45,7 +50,7 @@ export type WorkosCompleteAuthResult readonly guard: string readonly authProvider: string readonly status: WorkosSyncStatus - readonly user: TUser + readonly user: WorkosAuthenticatedUser readonly identity: HostedIdentityRecord readonly session: WorkosVerifiedSession readonly authSession?: AuthEstablishedSession @@ -112,7 +117,7 @@ export interface WorkosAuthenticationResult { readonly guard: string readonly authProvider: string readonly status: WorkosSyncStatus - readonly user: AuthUserLike + readonly user: WorkosAuthenticatedUser readonly identity: HostedIdentityRecord readonly session: WorkosVerifiedSession readonly authSession?: AuthEstablishedSession diff --git a/packages/auth-workos/src/index.ts b/packages/auth-workos/src/index.ts index aacb7f61..8282f491 100644 --- a/packages/auth-workos/src/index.ts +++ b/packages/auth-workos/src/index.ts @@ -1,6 +1,6 @@ import { createPublicKey, verify as verifySignature } from 'node:crypto' import { authRuntimeInternals, getAuthRuntime } from '@holo-js/auth' -import type { AuthEstablishedSession, AuthUserLike } from '@holo-js/auth' +import type { AuthenticatedAuthUser, AuthEstablishedSession, AuthUserLike } from '@holo-js/auth' import { parseCookieHeader } from '@holo-js/session' import type { NormalizedAuthWorkosProviderConfig } from '@holo-js/config' export { @@ -12,6 +12,7 @@ export type { HostedIdentityStore, WorkosAuthBindings, WorkosAuthFacade, + WorkosAuthenticatedUser, WorkosAuthenticationResult, WorkosCompleteAuthResult, WorkosIdentityProfile, @@ -103,7 +104,7 @@ type WorkosRequestLike = { type WorkosRequestInput = Request | WorkosRequestLike -type SerializedWorkosAuthUser = AuthUserLike & { +type SerializedWorkosAuthUser = AuthenticatedAuthUser & { readonly id: string | number } @@ -787,7 +788,7 @@ function serializeLocalUser( adapter: RuntimeAuthProviderAdapter, user: Record, providerName: string, -): AuthUserLike { +): SerializedWorkosAuthUser { const id = adapter.getId(user) const serialized = adapter.serialize ? adapter.serialize(user) @@ -802,8 +803,13 @@ function serializeLocalUser( enumerable: false, configurable: true, }) + Object.defineProperty(result, 'can', { + value: () => false, + enumerable: false, + configurable: true, + }) - return Object.freeze(result) + return Object.freeze(result) as SerializedWorkosAuthUser } function resolveDisplayName(profile: WorkosIdentityProfile): string { diff --git a/packages/auth/src/contracts.ts b/packages/auth/src/contracts.ts index 58c0c398..7f6931ec 100644 --- a/packages/auth/src/contracts.ts +++ b/packages/auth/src/contracts.ts @@ -146,8 +146,38 @@ export interface AuthUserLike { readonly [key: string]: unknown } +export interface AuthAuthorizable { + can(ability: string): boolean +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace HoloAuth { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + export interface TypeRegistry {} + } +} + // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface HoloAuthTypeRegistry {} +export interface HoloAuthTypeRegistry extends HoloAuth.TypeRegistry {} + +type AuthGuardDriverName = 'session' | 'token' + +type RegisteredAuthGuards = HoloAuth.TypeRegistry extends { + readonly guards: infer TGuards +} + ? TGuards + : Readonly> + +type RegisteredAuthGuardDriver = TName extends keyof RegisteredAuthGuards + ? Extract + : AuthGuardDriverName + +type AuthGuardFacadeForDriver = TDriver extends 'token' + ? AuthTokenGuardFacade + : AuthSessionGuardFacade + +export type AuthGuardFacadeFor = AuthGuardFacadeForDriver> export type AuthUser = HoloAuthTypeRegistry extends { readonly user: infer TUser @@ -157,6 +187,8 @@ export type AuthUser = HoloAuthTypeRegistry extends { : AuthUserLike : AuthUserLike +export type AuthenticatedAuthUser = AuthUser & AuthAuthorizable + export type HoloAuthUser = AuthUser export interface AuthCredentials extends Readonly> { @@ -199,9 +231,9 @@ export interface AuthImpersonationOptions extends AuthSessionLoginOptions { export interface AuthImpersonationState { readonly guard: string readonly actorGuard: string - readonly user: AuthUser - readonly actor: AuthUser - readonly originalUser: AuthUser | null + readonly user: AuthenticatedAuthUser + readonly actor: AuthenticatedAuthUser + readonly originalUser: AuthenticatedAuthUser | null readonly startedAt: Date } @@ -210,37 +242,69 @@ export interface AuthLogoutResult { readonly cookies: readonly string[] } -export interface AuthGuardFacade { +export type AuthGuardLoginData = AuthEstablishedSession | PersonalAccessTokenResult +export type AuthGuardRegistrationData = AuthenticatedAuthUser | PersonalAccessTokenResult + +export interface AuthBaseGuardFacade { check(): Promise - user(): Promise - refreshUser(): Promise + user(): Promise + refreshUser(): Promise provider(): Promise id(): Promise currentAccessToken(): Promise login( credentials: TCredentials, - ): Promise>> + ): Promise>> + register( + input: TInput, + ): Promise>> + logout(): Promise +} + +export interface AuthSessionOnlyFacade { loginUsing(user: unknown, options?: AuthSessionLoginOptions): Promise loginUsingId(userId: string | number, options?: AuthSessionLoginOptions): Promise impersonate(user: unknown, options?: AuthImpersonationOptions): Promise impersonateById(userId: string | number, options?: AuthImpersonationOptions): Promise impersonation(): Promise - stopImpersonating(): Promise - logout(): Promise + stopImpersonating(): Promise +} + +export interface AuthGuardFacade extends AuthBaseGuardFacade, AuthSessionOnlyFacade {} + +export interface AuthSessionGuardFacade extends AuthBaseGuardFacade, AuthSessionOnlyFacade { + login( + credentials: TCredentials, + ): Promise>> + register( + input: TInput, + ): Promise>> +} + +export interface AuthTokenGuardFacade extends AuthBaseGuardFacade { + login( + credentials: TCredentials, + ): Promise>> + register( + input: TInput, + ): Promise>> } export interface AuthFacade extends AuthGuardFacade { + login( + credentials: TCredentials, + ): Promise>> register( input: TInput, - ): Promise>> + ): Promise>> requestPasswordReset( input: TInput, options?: AuthPasswordResetRequestOptions, ): Promise>> resetPassword( input: TInput, - ): Promise>> - verifyEmail(token: string): Promise>> + ): Promise>> + verifyEmail(token: string): Promise>> sendEmailVerification(): Promise>> sendEmailVerification(email: string): Promise>> sendEmailVerification(email: string | undefined): Promise>> @@ -252,7 +316,7 @@ export interface AuthFacade extends AuthGuardFacade { hashPassword(password: string): Promise verifyPassword(password: string, digest: string): Promise needsPasswordRehash(digest: string): Promise - guard(name: string): AuthGuardFacade + guard(name: TName): string extends TName ? AuthGuardFacade : AuthGuardFacadeFor tokens: AuthTokenFacade verification: AuthEmailVerificationFacade } @@ -330,7 +394,7 @@ export interface AuthTokenFacade { list(user: unknown, options?: { readonly guard?: string }): Promise revoke(options?: { readonly guard?: string }): Promise revokeAll(user: unknown, options?: { readonly guard?: string }): Promise - authenticate(plainTextToken: string): Promise + authenticate(plainTextToken: string): Promise can(token: string, ability: string): Promise } @@ -339,6 +403,7 @@ export interface AuthCurrentAccessToken extends Omit } @@ -403,7 +468,7 @@ export interface PasswordResetTokenStore { export interface AuthDeliveryHook { sendEmailVerification(input: { readonly provider: string - readonly user: AuthUser + readonly user: AuthenticatedAuthUser readonly email: string readonly token: EmailVerificationTokenResult readonly route: string @@ -420,7 +485,7 @@ export interface AuthDeliveryHook { export interface AuthEmailVerificationFacade { create(user: unknown, options?: { readonly guard?: string, readonly expiresAt?: Date }): Promise resend(options?: { readonly guard?: string, readonly expiresAt?: Date, readonly email?: string }): Promise>> - consume(plainTextToken: string): Promise>> + consume(plainTextToken: string): Promise>> } export interface AuthSessionRecord { @@ -478,8 +543,8 @@ export interface AuthSessionRuntime { export interface AuthRuntimeContext { getSessionId(guardName: string): string | undefined setSessionId(guardName: string, sessionId?: string): void - getCachedUser(guardName: string): AuthUser | null | undefined - setCachedUser(guardName: string, user: AuthUser | null): void + getCachedUser(guardName: string): AuthenticatedAuthUser | null | undefined + setCachedUser(guardName: string, user: AuthenticatedAuthUser | null): void getRequestCookie?(name: string): string | undefined | Promise getRequestHeader?(name: string): string | undefined | Promise appendResponseCookie?(cookie: string): void | Promise @@ -509,7 +574,7 @@ export interface AuthRuntimeFacade extends AuthFacade { export interface AuthEstablishedSession { readonly guard: string readonly provider: string - readonly user: AuthUser + readonly user: AuthenticatedAuthUser readonly sessionId: string readonly rememberToken?: string readonly cookies: readonly string[] @@ -521,7 +586,7 @@ export interface CurrentAuthResponse { readonly authenticated: boolean readonly guard: string readonly provider: string | null - readonly user: AuthUser | null + readonly user: HoloAuthUser | null } export interface AuthClientConfig { diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 11371c58..4c73e5b7 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,4 +1,5 @@ import { check, currentAccessToken, getAuthRuntime, hashPassword, id, impersonate, impersonateById, impersonation, login, loginUsing, loginUsingId, logout, needsPasswordRehash, provider, refreshUser, register, requestPasswordReset, resendEmailVerification, resetPassword, sendEmailVerification, stopImpersonating, tokens, user, verification, verifyEmail, verifyPassword } from './runtime' +import type { AuthFacade } from './contracts' export { AUTH_ERROR_CODES, AuthError, defineAuthConfig, isAuthError } from './contracts' export { @@ -35,6 +36,8 @@ export { } from './runtime' export type { AuthFailure, + AuthenticatedAuthUser, + AuthAuthorizable, AuthClientConfig, AuthClientRequestOptions, AuthCredentials, @@ -47,10 +50,16 @@ export type { AuthEmailVerificationSendOptions, AuthEstablishedSession, AuthFacade, + AuthBaseGuardFacade, AuthFieldErrors, AuthFailureResult, AuthGuardFacade, + AuthGuardFacadeFor, + AuthGuardLoginData, + AuthGuardRegistrationData, AuthInputFieldErrors, + AuthSessionGuardFacade, + AuthSessionOnlyFacade, AuthImpersonationOptions, AuthImpersonationState, AuthLoginErrorCode, @@ -66,6 +75,7 @@ export type { AuthResult, AuthSuccessResult, AuthTokenFacade, + AuthTokenGuardFacade, AuthTokenStore, AuthUser, HoloAuthUser, @@ -92,7 +102,7 @@ export type { PasswordResetTokenStore, } from './contracts' -const auth = Object.freeze({ +const auth: AuthFacade = Object.freeze({ check, user, refreshUser, @@ -118,7 +128,7 @@ const auth = Object.freeze({ verification, verifyEmail, verifyPassword, - guard(name: string) { + guard(name: TName) { return getAuthRuntime().guard(name) }, }) diff --git a/packages/auth/src/runtime.ts b/packages/auth/src/runtime.ts index d97761ca..c6cb0682 100644 --- a/packages/auth/src/runtime.ts +++ b/packages/auth/src/runtime.ts @@ -1,6 +1,7 @@ import { createHash, createHmac } from 'node:crypto' import { normalizeAuthConfig } from '@holo-js/config' import type { + AuthenticatedAuthUser, AuthFieldErrors, AuthCredentials, AuthCurrentAccessToken, @@ -12,6 +13,7 @@ import type { AuthEstablishedSession, AuthFacade, AuthGuardFacade, + AuthGuardFacadeFor, AuthImpersonationOptions, AuthImpersonationState, AuthLoginErrorCode, @@ -25,7 +27,9 @@ import type { AuthRegistrationErrorCode, AuthResult, AuthRegistrationInput, + AuthSessionGuardFacade, AuthSessionLoginOptions, + AuthTokenGuardFacade, AuthTokenFacade, AuthTokenStore, AuthUser, @@ -100,7 +104,7 @@ export { createMemoryAuthContext, } from './runtime/context' -type SerializedAuthUser = AuthUser & { +type SerializedAuthUser = AuthenticatedAuthUser & { readonly id: string | number } @@ -582,6 +586,7 @@ function serializeUser( adapter: ErasedAuthProviderAdapter, user: unknown, providerName?: string, + options: { readonly can?: (ability: string) => boolean } = {}, ): SerializedAuthUser { const serialized = adapter.serialize ? adapter.serialize(user) @@ -594,6 +599,11 @@ function serializeUser( ...requireRecordValue(serialized, '[@holo-js/auth] Auth provider serialize() must return an object user.'), id, } + Object.defineProperty(result, 'can', { + value: options.can ?? (() => false), + enumerable: false, + configurable: true, + }) if (providerName) { Object.defineProperty(result, AUTH_PROVIDER_MARKER, { value: providerName, @@ -613,6 +623,11 @@ function rehydrateSerializedUser( ...user, id: user.id, } + Object.defineProperty(restored, 'can', { + value: () => false, + enumerable: false, + configurable: true, + }) Object.defineProperty(restored, AUTH_PROVIDER_MARKER, { value: providerName, enumerable: false, @@ -633,6 +648,19 @@ function getPasswordHash( return typeof value === 'string' ? value : null } +async function verifyCredentialsForProvider( + providerName: string, + adapter: ErasedAuthProviderAdapter, + credentials: AuthCredentials, +): Promise | null> { + const bindings = getRuntimeBindings() + const user = await findUserByConfiguredIdentifiers(adapter, credentials, getProviderIdentifiers(providerName)) + const passwordHash = user ? getPasswordHash(adapter, user) : null + const passwordMatches = await bindings.passwordHasher.verify(credentials.password, passwordHash ?? '') + + return user && passwordMatches ? user : null +} + function isEmailVerificationRequired(): boolean { return getRuntimeBindings().config.emailVerification.required === true } @@ -899,7 +927,7 @@ async function authenticateAccessTokenRecord( plainTextToken: string, ): Promise<{ readonly token: PersonalAccessTokenRecord - readonly user: AuthUser + readonly user: SerializedAuthUser } | null> { const parsed = parsePlainTextToken(plainTextToken) if (!parsed) { @@ -926,7 +954,9 @@ async function authenticateAccessTokenRecord( return { token: updatedRecord, - user: serializeUser(adapter, resolvedUser, tokenRecord.provider), + user: serializeUser(adapter, resolvedUser, tokenRecord.provider, { + can: ability => tokenHasAbility(updatedRecord, ability), + }), } } @@ -934,8 +964,11 @@ function createCurrentAccessTokenHandle( guardName: string, record: PersonalAccessTokenRecord, ): AuthCurrentAccessToken { + const normalizedRecord = normalizeTokenRecord(record) + return Object.freeze({ - ...normalizeTokenRecord(record), + ...normalizedRecord, + can: (ability: string) => tokenHasAbility(normalizedRecord, ability), delete: async () => { await ensureTokenStore().delete(record.id) const bindings = getRuntimeBindings() @@ -977,7 +1010,7 @@ async function resolveCurrentAccessTokenForGuard(guardName: string): Promise { +): Promise { const bindings = getRuntimeBindings() const guard = getGuardConfig(guardName) @@ -1051,19 +1084,13 @@ async function resolveUserFromGuard( return serialized } -async function loginForGuard(guardName: string, credentials: AuthCredentials): Promise { - const bindings = getRuntimeBindings() +async function loginForSessionGuard(guardName: string, credentials: AuthCredentials): Promise { const { guard, adapter } = getGuardProviderAdapter(guardName) - const user = await findUserByConfiguredIdentifiers(adapter, credentials, getProviderIdentifiers(guard.provider)) + const user = await verifyCredentialsForProvider(guard.provider, adapter, credentials) if (!user) { throwAuthError('invalid_credentials', 'Invalid credentials.') } - const passwordHash = getPasswordHash(adapter, user) - if (!passwordHash || !(await bindings.passwordHasher.verify(credentials.password, passwordHash))) { - throwAuthError('invalid_credentials', 'Invalid credentials.') - } - const serialized = serializeUser(adapter, user, guard.provider) await hydrateGuardContextFromRequest(guardName) return establishSessionForUser(serialized, { @@ -1073,6 +1100,22 @@ async function loginForGuard(guardName: string, credentials: AuthCredentials): P }) } +async function loginForGuard(guardName: string, credentials: AuthCredentials): Promise { + const guard = getGuardConfig(guardName) + if (guard.driver === 'session') { + return loginForSessionGuard(guardName, credentials) + } + + const { adapter } = getProviderAdapter(guard.provider) + const user = await verifyCredentialsForProvider(guard.provider, adapter, credentials) + if (!user) { + throwAuthError('invalid_credentials', 'Invalid credentials.') + } + + const serialized = serializeUser(adapter, user, guard.provider) + return createLoginTokenForGuard(guardName, serialized, credentials) +} + function assertTrustedUserProvider( guardName: string, providerName: string, @@ -1155,7 +1198,7 @@ function isCompatibleSerializedUserCandidate( return false } - const serializedRecord = serialized as Readonly> + const serializedRecord = serialized as unknown as Readonly> for (const [key, value] of Object.entries(candidate)) { if (typeof value === 'undefined') { @@ -1449,7 +1492,7 @@ async function impersonationForGuard(guardName: string): Promise { +async function stopImpersonatingForGuard(guardName: string): Promise { const bindings = getRuntimeBindings() const state = await readGuardSessionState(guardName) if (!state || !state.payload.impersonation) { @@ -1532,12 +1575,11 @@ async function logoutForGuard(guardName: string): Promise { }) } -async function registerDefaultUser(input: AuthRegistrationInput): Promise { +async function registerUserForGuard(guardName: string, input: AuthRegistrationInput): Promise { ensurePasswordConfirmation(input) const bindings = getRuntimeBindings() - const defaultGuard = bindings.config.defaults.guard - const guard = getGuardConfig(defaultGuard) + const guard = getGuardConfig(guardName) const { adapter } = getProviderAdapter(guard.provider) const identifiers = getProviderIdentifiers(guard.provider) const lookup = toLookupCredentials(input, identifiers) @@ -1558,7 +1600,7 @@ async function registerDefaultUser(input: AuthRegistrationInput): Promise undefined) @@ -1570,6 +1612,26 @@ async function registerDefaultUser(input: AuthRegistrationInput): Promise { + return registerUserForGuard(getRuntimeBindings().config.defaults.guard, input) +} + +async function registerForGuard(guardName: string, input: AuthRegistrationInput): Promise { + const guard = getGuardConfig(guardName) + if (guard.driver === 'token') { + ensureTokenStore() + const user = await registerUserForGuard(guardName, input) + try { + return await createLoginTokenForGuard(guardName, user, input) + } catch (error) { + await rollbackSerializedUserForGuard(guardName, user).catch(() => undefined) + throw error + } + } + + return registerUserForGuard(guardName, input) +} + async function rollbackRegisteredUser( adapter: ErasedAuthProviderAdapter, createdUser: unknown, @@ -1600,6 +1662,30 @@ async function rollbackRegisteredUser( } } +async function rollbackSerializedUserForGuard(guardName: string, user: SerializedAuthUser): Promise { + const guard = getGuardConfig(guardName) + const { adapter } = getProviderAdapter(guard.provider) + let adapterDeleteError: unknown + if (adapter.delete) { + try { + await adapter.delete(user.id) + return + } catch (error) { + adapterDeleteError = error + } + } + + const createdUser = await adapter.findById(user.id) + if (createdUser && typeof createdUser === 'object' && 'delete' in createdUser && typeof createdUser.delete === 'function') { + await createdUser.delete() + return + } + + if (adapterDeleteError) { + throw adapterDeleteError + } +} + function findProviderNameForUser(user: unknown): string { const bindings = getRuntimeBindings() const providerNames = Object.keys(bindings.providers) @@ -1628,7 +1714,7 @@ function findProviderNameForUser(user: unknown): string { } async function establishSessionForUser( - user: AuthUser, + user: SerializedAuthUser, options: { readonly guard: string readonly provider: string @@ -1659,7 +1745,7 @@ async function establishSessionForUser( && existingPayloads[options.guard] ) const sessionPayload = options.payload - ?? toSessionPayload(options.guard, options.provider, user as SerializedAuthUser) + ?? toSessionPayload(options.guard, options.provider, user) const sessionPayloads = { ...existingPayloads, [options.guard]: sessionPayload, @@ -1725,7 +1811,7 @@ async function establishSessionForUser( sessionId: session.id, rememberToken, cookies: Object.freeze(cookies), - ...(isEmailVerificationRequired() && !hasVerifiedEmail(user as Readonly>) + ...(isEmailVerificationRequired() && !hasVerifiedEmail(user as unknown as Readonly>) ? { emailVerificationRequired: true, emailVerificationRoute: createEmailVerificationRedirectRoute(user), @@ -1751,6 +1837,26 @@ function toPlainTextTokenResult( }) } +function getTokenAbilitiesFromInput(input?: Readonly>): readonly string[] | undefined { + const abilities = input?.abilities + return Array.isArray(abilities) && abilities.every(ability => typeof ability === 'string') + ? abilities + : undefined +} + +function createLoginTokenForGuard( + guardName: string, + user: AuthUser, + input?: Readonly>, +): Promise { + const abilities = getTokenAbilitiesFromInput(input) + return createTokenFacade().create(user, { + guard: guardName, + name: guardName, + ...(abilities ? { abilities } : {}), + }) +} + async function updateUserRecord( providerName: string, userId: string | number, @@ -1761,7 +1867,7 @@ async function updateUserRecord( readonly email_verified_at?: Date | null readonly password?: string | null }, -): Promise { +): Promise { const { adapter } = getProviderAdapter(providerName) const user = await adapter.findById(userId) if (!user) { @@ -1838,7 +1944,7 @@ function createEmailVerificationFacade(): AuthEmailVerificationFacade { resend(options: { readonly guard?: string, readonly expiresAt?: Date, readonly email?: string } = {}): Promise>> { return captureExpectedAuthResult(async () => { const guardName = options.guard ?? getDefaultGuardName() - let currentUser: AuthUser | null + let currentUser: AuthenticatedAuthUser | null if (typeof options.email === 'string' && options.email.trim().length > 0) { const { provider, adapter } = getGuardProviderAdapter(guardName) const matchedUser = await adapter.findByCredentials({ @@ -1857,7 +1963,7 @@ function createEmailVerificationFacade(): AuthEmailVerificationFacade { }) } - if (hasVerifiedEmail(currentUser as Readonly>)) { + if (hasVerifiedEmail(currentUser as unknown as Readonly>)) { throwAuthError('email_already_verified', 'Your email address is already verified.', { guard: guardName, }) @@ -1869,7 +1975,7 @@ function createEmailVerificationFacade(): AuthEmailVerificationFacade { }) }, EXPECTED_EMAIL_VERIFICATION_RESEND_ERRORS, createEmailVerificationResendFailure) }, - consume(plainTextToken: string): Promise>> { + consume(plainTextToken: string): Promise>> { return captureExpectedAuthResult(async () => { const parsed = parsePlainTextToken(plainTextToken) if (!parsed) { @@ -2002,7 +2108,7 @@ async function requestPasswordResetUsingRuntime( input: TInput, -): Promise, readonly string[]>>>> { +): Promise, readonly string[]>>>> { return captureExpectedAuthResult(async () => { if (input.password !== input.passwordConfirmation) { throwAuthError('password_confirmation_mismatch', 'Password confirmation does not match.') @@ -2112,7 +2218,7 @@ function createTokenFacade(): AuthTokenFacade { ) return tokenStore.deleteByUserId(providerName, userId) }, - async authenticate(plainTextToken: string): Promise { + async authenticate(plainTextToken: string): Promise { const authenticated = await authenticateAccessTokenRecord(plainTextToken) return authenticated?.user ?? null }, @@ -2123,8 +2229,8 @@ function createTokenFacade(): AuthTokenFacade { }) } -function createGuardFacade(guardName: string): AuthGuardFacade { - return Object.freeze({ +function createGuardFacade(guardName: string): AuthSessionGuardFacade | AuthTokenGuardFacade { + const base = { check() { return checkForGuard(guardName) }, @@ -2150,6 +2256,24 @@ function createGuardFacade(guardName: string): AuthGuardFacade { error => createLoginFailure(error, credentials), ) }, + register(input: TInput) { + return captureExpectedAuthResult( + () => registerForGuard(guardName, input), + EXPECTED_REGISTRATION_ERRORS, + error => createRegistrationFailure(error, input), + ) + }, + logout() { + return logoutForGuard(guardName) + }, + } + + if (getGuardConfig(guardName).driver === 'token') { + return Object.freeze(base) as AuthTokenGuardFacade + } + + return Object.freeze({ + ...base, loginUsing(user: unknown, options?: AuthSessionLoginOptions) { return loginUsingForGuard(guardName, user, options) }, @@ -2168,10 +2292,7 @@ function createGuardFacade(guardName: string): AuthGuardFacade { stopImpersonating() { return stopImpersonatingForGuard(guardName) }, - logout() { - return logoutForGuard(guardName) - }, - }) + }) as AuthSessionGuardFacade } export function configureAuthRuntime(bindings?: AuthRuntimeBindings): void { @@ -2180,8 +2301,17 @@ export function configureAuthRuntime(bindings?: AuthRuntimeBindings): void { return } + const config = normalizeAuthConfig(bindings.config) + const defaultGuard = config.guards[config.defaults.guard] + if (defaultGuard?.driver === 'token') { + throw new Error( + `[@holo-js/auth] The default auth guard "${config.defaults.guard}" uses the token driver. ` + + 'Use a session-backed default guard for top-level login/register, or call auth.guard(name).login/register for token guards.', + ) + } + getAuthRuntimeState().bindings = { - config: normalizeAuthConfig(bindings.config), + config, session: bindings.session, providers: bindings.providers, tokens: bindings.tokens, @@ -2219,7 +2349,7 @@ export function getAuthRuntime(): AuthRuntimeFacade { }, login(credentials: TCredentials) { return captureExpectedAuthResult( - () => loginForGuard(getDefaultGuardName(), credentials), + () => loginForSessionGuard(getDefaultGuardName(), credentials), EXPECTED_LOGIN_ERRORS, error => createLoginFailure(error, credentials), ) @@ -2276,8 +2406,8 @@ export function getAuthRuntime(): AuthRuntimeFacade { needsPasswordRehash(digest: string) { return resolveNeedsPasswordRehash(getRuntimeBindings().passwordHasher, digest) }, - guard(name: string) { - return createGuardFacade(name) + guard(name: TName): string extends TName ? AuthGuardFacade : AuthGuardFacadeFor { + return createGuardFacade(name) as string extends TName ? AuthGuardFacade : AuthGuardFacadeFor }, tokens, verification, @@ -2313,11 +2443,11 @@ export async function checkForGuard(guardName: string): Promise { return (await userForGuard(guardName)) !== null } -export async function userForGuard(guardName: string): Promise { +export async function userForGuard(guardName: string): Promise { return resolveUserFromGuard(guardName) } -export async function refreshUserForGuard(guardName: string): Promise { +export async function refreshUserForGuard(guardName: string): Promise { return resolveUserFromGuard(guardName, { fresh: true }) } @@ -2347,11 +2477,11 @@ export async function check(): Promise { return getAuthRuntime().check() } -export async function user(): Promise { +export async function user(): Promise { return getAuthRuntime().user() } -export async function refreshUser(): Promise { +export async function refreshUser(): Promise { return getAuthRuntime().refreshUser() } @@ -2405,7 +2535,7 @@ export async function impersonation(): Promise { return getAuthRuntime().impersonation() } -export async function stopImpersonating(): Promise { +export async function stopImpersonating(): Promise { return getAuthRuntime().stopImpersonating() } @@ -2436,7 +2566,7 @@ export async function logout(): Promise { export async function register( input: TInput, -): Promise, readonly string[]>>>> { +): Promise, readonly string[]>>>> { return getAuthRuntime().register(input) } @@ -2449,13 +2579,13 @@ export async function requestPasswordReset( input: TInput, -): Promise, readonly string[]>>>> { +): Promise, readonly string[]>>>> { return getAuthRuntime().resetPassword(input) } export function verifyEmail( token: string, -): Promise>> { +): Promise>> { return getAuthRuntime().verifyEmail(token) } diff --git a/packages/auth/src/runtime/context.ts b/packages/auth/src/runtime/context.ts index a12c111e..c0fb1a7e 100644 --- a/packages/auth/src/runtime/context.ts +++ b/packages/auth/src/runtime/context.ts @@ -1,9 +1,9 @@ import { AsyncLocalStorage } from 'node:async_hooks' -import type { AuthRuntimeContext, AuthUser } from '../contracts' +import type { AuthenticatedAuthUser, AuthRuntimeContext } from '../contracts' export type MemoryAuthContext = AuthRuntimeContext & { readonly sessionIds: Map - readonly cachedUsers: Map + readonly cachedUsers: Map readonly accessTokens: Map readonly rememberTokens: Map getAccessToken(guardName: string): string | undefined @@ -27,7 +27,7 @@ function setMapValue(map: Map, key: string, value?: string): voi export function createMemoryAuthContext(): MemoryAuthContext { const sessionIds = new Map() - const cachedUsers = new Map() + const cachedUsers = new Map() const accessTokens = new Map() const rememberTokens = new Map() diff --git a/packages/auth/tests/contracts.type.test.ts b/packages/auth/tests/contracts.type.test.ts index 70407958..100a567c 100644 --- a/packages/auth/tests/contracts.type.test.ts +++ b/packages/auth/tests/contracts.type.test.ts @@ -1,5 +1,5 @@ import { describe, expectTypeOf, it } from 'vitest' -import auth, { AuthError, isAuthError, type AuthEmailVerificationConsumeErrorCode, type AuthEmailVerificationResendErrorCode, type AuthErrorCode, type AuthEstablishedSession, type AuthFailure, type AuthFieldErrors, type AuthGuardFacade, type AuthImpersonationState, type AuthLoginErrorCode, type AuthLogoutResult, type AuthPasswordResetConsumeErrorCode, type AuthPasswordResetRequestErrorCode, type AuthProviderAdapter, type AuthRegistrationErrorCode, type AuthResult, type AuthRuntimeBindings, type AuthUser, type CurrentAuthResponse, type EmailVerificationTokenResult, type getAuthRuntime, type HoloAuthUser, type register, type user, type verifyEmail } from '../src' +import auth, { AuthError, isAuthError, type AuthenticatedAuthUser, type AuthEmailVerificationConsumeErrorCode, type AuthEmailVerificationResendErrorCode, type AuthErrorCode, type AuthEstablishedSession, type AuthFailure, type AuthFieldErrors, type AuthGuardFacade, type AuthImpersonationState, type AuthLoginErrorCode, type AuthLogoutResult, type AuthPasswordResetConsumeErrorCode, type AuthPasswordResetRequestErrorCode, type AuthProviderAdapter, type AuthRegistrationErrorCode, type AuthResult, type AuthRuntimeBindings, type AuthUser, type CurrentAuthResponse, type EmailVerificationTokenResult, type getAuthRuntime, type HoloAuthUser, type PersonalAccessTokenResult, type register, type user, type verifyEmail } from '../src' import clientAuth, { type provider as clientProvider, type refreshUser as refreshClientUser, type useAuth as clientUseAuth, type user as clientUser } from '../src/client' import type { useAuth as useNextAuth } from '../src/next/client' import type { useAuth as useNuxtAuth } from '../src/nuxt' @@ -17,6 +17,19 @@ declare module '../src' { } } +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace HoloAuth { + export interface TypeRegistry { + guards: { + readonly web: 'session' + readonly admin: 'session' + readonly api: 'token' + } + } + } +} + describe('@holo-js/auth typing', () => { it('preserves the augmented auth user shape across server and client helpers', () => { type AppAuthUser = { @@ -26,6 +39,9 @@ describe('@holo-js/auth typing', () => { readonly role: 'admin' | 'member' readonly avatarUrl?: string | null } + type AppAuthenticatedUser = AppAuthUser & { + can(ability: string): boolean + } type RegisteredUser = Awaited> type CurrentServerUser = Awaited> @@ -39,6 +55,15 @@ describe('@holo-js/auth typing', () => { type GuardProvider = Awaited> type GuardUser = Awaited> type GuardRefreshedUser = Awaited> + type WebGuardLogin = Awaited>['login']>> + type ApiGuardLogin = Awaited>['login']>> + type ApiGuardRegister = Awaited>['register']>> + type ApiGuardHasLoginUsing = 'loginUsing' extends keyof ReturnType> ? true : false + type ApiGuardHasImpersonate = 'impersonate' extends keyof ReturnType> ? true : false + type DynamicGuard = ReturnType> + type DynamicGuardTrustedSession = Awaited> + type GuardLogin = Awaited> + type GuardRegister = Awaited> type TrustedSession = Awaited> type TrustedIdSession = Awaited> type ImpersonatedSession = Awaited> @@ -48,9 +73,10 @@ describe('@holo-js/auth typing', () => { type RuntimeLogoutAll = Awaited['logoutAll']>> expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf>() - expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf boolean @@ -65,13 +91,21 @@ describe('@holo-js/auth typing', () => { expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf>() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf
Generate a bearer token from credentials, then use it to fetch protected posts.
{JSON.stringify(tokenResult.payload, null, 2)}
{JSON.stringify(postsResult.payload, null, 2)}
{{ JSON.stringify(tokenResult.payload, null, 2) }}
{{ JSON.stringify(postsResult.payload, null, 2) }}