Skip to content
← Public packages

@kentcdodds/package-app-kit

Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.

FollowFork

src/auth.ts

447 lines · 14.7 KB · TypeScript
/**
 * Near-zero-config auth helper for hosted Kody package apps.
 *
 * The platform owns reserved paths at the **host root** of every package-app
 * host (`*.kody.run` and verified custom domains), never under the package mount (`/{leaf}` or legacy `/packages/{leaf}`):
 *
 * - `GET  /-/auth/login?app={leaf}&return_to=…&scope=email` start sign-in
 * - `GET  /-/auth/callback` platform-only code exchange
 * - `GET|POST /-/auth/logout?app={leaf}&return_to=…` clear only that app's
 *   package-app session (shared hosts keep sibling app sessions)
 * - `GET  /-/auth/me?app={leaf}` JSON current user or 401
 *
 * Legacy `/_kody/auth/*` paths remain accepted by the platform.
 *
 * On preview / app-origin (path-based `/@owner/packages/{leaf}`), login,
 * logout, and `/me` also need `org` (or `username`) so host-auth can resolve
 * identity when `PACKAGE_APP_BASE_URL` is unset.
 *
 * This module only builds those URLs, redirects to them, and reads who is
 * signed in. It never talks to kody.codes OAuth endpoints and has no `kody:`
 * imports. Pass `hostedUrl` / `origin` from `packageContext` when you have it
 * (or explicit `app` / `org` / `username`).
 *
 * Reading the user in server code: the platform strips `Cookie` before app
 * code runs and instead sets `X-Kody-Visitor` (verified JSON `userId`,
 * `username`, `role`) on the request after removing any inbound `X-Kody-*`
 * header, so it cannot be forged. `getUser` reads that header first and only
 * falls back to forwarding `Cookie` to `/me` when it is absent (browser-style
 * requests, local tools).
 */

export const PACKAGE_APP_VISITOR_HEADER = 'X-Kody-Visitor'

export const PACKAGE_APP_AUTH_PREFIX = '/-/auth'
export const PACKAGE_APP_AUTH_LEGACY_PREFIX = '/_kody/auth'
export const PACKAGE_APP_AUTH_LOGIN_PATH = `${PACKAGE_APP_AUTH_PREFIX}/login`
export const PACKAGE_APP_AUTH_LOGOUT_PATH = `${PACKAGE_APP_AUTH_PREFIX}/logout`
export const PACKAGE_APP_AUTH_ME_PATH = `${PACKAGE_APP_AUTH_PREFIX}/me`

/** Scopes beyond the default profile (id, name, avatar, role). */
export type PackageAppAuthScope = 'email'

export type PackageAppAuthMode = 'required' | 'optional'

/** JSON returned by `GET /-/auth/me`. */
export type PackageAppUser = {
	userId: string
	username: string
	displayName: string
	avatarUrl?: string
	/** Present only when the user consented to the `email` scope. */
	email?: string
	role: string
}

export type PackageAppAuthOptions = {
	/**
	 * `'required'` makes `resolveUser` / `requireUser` redirect anonymous users
	 * to login; `'optional'` (default) allows anonymous.
	 */
	mode?: PackageAppAuthMode
	/** Extra scopes beyond profile. `email` requires user consent. */
	scopes?: ReadonlyArray<PackageAppAuthScope>
	/**
	 * Host origin for absolute URLs (for example `https://notes.kody.run`).
	 * Wins over `hostedUrl`. Defaults to the incoming request's origin, or to
	 * host-root relative paths when neither request nor origin is available.
	 */
	origin?: string | null
	/**
	 * `packageContext.hostedUrl`. Its origin is used for absolute URLs; its
	 * path also supplies package identity (`app` leaf, and `org` on
	 * `/@owner/packages/{leaf}` mounts).
	 */
	hostedUrl?: string | null
	/**
	 * Package name leaf for host-auth `?app=`. Derived from `hostedUrl` when
	 * omitted (subdomain `/{leaf}` or app-origin `/@owner/packages/{leaf}`).
	 */
	app?: string | null
	/**
	 * Publisher org / username handle for host-auth `?org=` on app-origin /
	 * preview. Alias of `username`. Derived from `hostedUrl` when omitted.
	 */
	org?: string | null
	/**
	 * Alias of `org` (platform accepts `org`, `username`, or `owner`).
	 */
	username?: string | null
	/** Override `fetch` (tests, service bindings). Defaults to global `fetch`. */
	fetch?: (request: Request) => Promise<Response>
}

export type PackageAppAuthUrlInput = {
	/**
	 * Where to land afterwards: a host-root path (`/notes?tab=1`) or an
	 * absolute URL on this host. Defaults to the platform's own default.
	 */
	returnTo?: string | null
	/** Request used to infer the origin when `origin` / `hostedUrl` are unset. */
	request?: Request
}

export type PackageAppAuthLoginInput = PackageAppAuthUrlInput & {
	/** Replaces the helper-level `scopes` for this URL only. */
	scopes?: ReadonlyArray<PackageAppAuthScope>
}

export type PackageAppAuth = {
	readonly mode: PackageAppAuthMode
	readonly scopes: ReadonlyArray<PackageAppAuthScope>
	/** `/-/auth/login?app=…&return_to=…` — absolute when an origin is known, else host-root path. */
	loginUrl(input?: PackageAppAuthLoginInput): string
	/** `/-/auth/logout?app=…&return_to=…` — works as a link (GET) or form action (POST). */
	logoutUrl(input?: PackageAppAuthUrlInput): string
	/** `/-/auth/me?app=…` (plus `org` on app-origin when known). */
	meUrl(input?: Pick<PackageAppAuthUrlInput, 'request'>): string
	/**
	 * Current user, or `null` when anonymous. In package-app server code this is
	 * the platform's verified `X-Kody-Visitor` header (no network). Otherwise it
	 * forwards the request's Cookie to `/-/auth/me`.
	 */
	getUser(request: Request): Promise<PackageAppUser | null>
	/** 302 to login, returning to the request's path + search. Use `throw` in loaders. */
	redirectToLogin(request: Request, input?: Omit<PackageAppAuthLoginInput, 'request'>): Response
	/** The signed-in user, or throws `redirectToLogin(request)` when anonymous. */
	requireUser(request: Request): Promise<PackageAppUser>
	/** Honors `mode`: `'required'` behaves like `requireUser`, `'optional'` like `getUser`. */
	resolveUser(request: Request): Promise<PackageAppUser | null>
}

/** Thrown by `getUser` when `/-/auth/me` fails for a reason other than "not signed in". */
export class PackageAppAuthError extends Error {
	readonly status: number
	constructor(message: string, status: number) {
		super(message)
		this.name = 'PackageAppAuthError'
		this.status = status
	}
}

type PackageAppAuthIdentity = {
	app: string | null
	org: string | null
	/** True when identity came from (or serves on) a path-based `/@owner/packages/…` mount. */
	appOriginMount: boolean
}

function safeOrigin(value: string | null | undefined): string | null {
	if (!value) return null
	try {
		return new URL(value).origin
	} catch {
		return null
	}
}

function normalizeHandle(value: string | null | undefined): string | null {
	if (!value) return null
	const trimmed = value.trim().toLowerCase()
	return trimmed || null
}

function decodePathSegment(raw: string): string | null {
	try {
		return decodeURIComponent(raw)
	} catch {
		return null
	}
}

/**
 * Derive package leaf + publisher handle from a hosted mount URL.
 * Supports subdomain short (`https://owner.kody.run/leaf`) and legacy
 * (`https://owner.kody.run/packages/leaf`) mounts, plus app-origin path mounts
 * (`https://kody.codes/@owner/packages/leaf`).
 */
export function parsePackageAppAuthIdentityFromHostedUrl(
	hostedUrl: string | null | undefined,
): PackageAppAuthIdentity {
	if (!hostedUrl) return { app: null, org: null, appOriginMount: false }
	try {
		const url = new URL(hostedUrl)
		const parts = url.pathname.split('/').filter(Boolean)
		if (parts[0]?.startsWith('@') && parts[1] === 'packages' && parts[2]) {
			const org = normalizeHandle(decodePathSegment(parts[0].slice(1)))
			const app = normalizeHandle(decodePathSegment(parts[2]))
			return { app, org, appOriginMount: true }
		}
		if (parts[0] === 'packages' && parts[1]) {
			const app = normalizeHandle(decodePathSegment(parts[1]))
			const hostLabel = url.hostname.split('.')[0] ?? ''
			const org = normalizeHandle(hostLabel)
			return { app, org, appOriginMount: false }
		}
		// Short subdomain mount: https://owner.kody.run/{leaf}
		if (parts.length >= 1 && parts[0] && parts[0] !== 'packages' && !parts[0].startsWith('@') && parts[0] !== '-') {
			const app = normalizeHandle(decodePathSegment(parts[0]))
			const hostLabel = url.hostname.split('.')[0] ?? ''
			const org = normalizeHandle(hostLabel)
			return { app, org, appOriginMount: false }
		}
		return { app: null, org: null, appOriginMount: false }
	} catch {
		return { app: null, org: null, appOriginMount: false }
	}
}

function resolveIdentity(options: PackageAppAuthOptions): PackageAppAuthIdentity {
	const fromUrl = parsePackageAppAuthIdentityFromHostedUrl(options.hostedUrl)
	const app = normalizeHandle(options.app) ?? fromUrl.app
	const org =
		normalizeHandle(options.org) ??
		normalizeHandle(options.username) ??
		fromUrl.org
	return {
		app,
		org,
		appOriginMount: fromUrl.appOriginMount,
	}
}

/**
 * Host-auth on `*.kody.run` user subdomains only needs `app` (org is the Host
 * label). App-origin / preview (path-based `/@owner/packages/…`, or any host
 * that is not `{org}.…`) also needs `org`/`username`.
 */
function shouldAttachOrg(
	identity: PackageAppAuthIdentity,
	request: Request | undefined,
	configuredOrigin: string | null,
): boolean {
	if (!identity.org) return false
	if (identity.appOriginMount) return true

	const originStr =
		configuredOrigin ?? (request ? new URL(request.url).origin : null)
	if (!originStr) {
		// Relative URLs with no known host: include org when we have it so
		// preview / inline mounts still work.
		return true
	}
	try {
		const host = new URL(originStr).hostname.toLowerCase()
		if (host === identity.org || host.startsWith(`${identity.org}.`)) {
			return false
		}
		return true
	} catch {
		return true
	}
}

function identityQueryParams(
	identity: PackageAppAuthIdentity,
	request: Request | undefined,
	configuredOrigin: string | null,
): Record<string, string | null | undefined> {
	const params: Record<string, string | null | undefined> = {
		app: identity.app,
	}
	if (shouldAttachOrg(identity, request, configuredOrigin)) {
		params.org = identity.org
	}
	return params
}

function normalizeScopes(
	scopes: ReadonlyArray<PackageAppAuthScope> | undefined,
): Array<PackageAppAuthScope> {
	return [...new Set(scopes ?? [])]
}

function parseUser(value: unknown): PackageAppUser {
	if (!value || typeof value !== 'object') {
		throw new PackageAppAuthError('Invalid /-/auth/me response', 502)
	}
	const raw = value as Record<string, unknown>
	if (typeof raw.userId !== 'string' || !raw.userId) {
		throw new PackageAppAuthError('Invalid /-/auth/me response', 502)
	}
	const user: PackageAppUser = {
		userId: raw.userId,
		username: typeof raw.username === 'string' ? raw.username : '',
		displayName:
			typeof raw.displayName === 'string' ? raw.displayName : String(raw.username ?? ''),
		role: typeof raw.role === 'string' ? raw.role : 'user',
	}
	if (typeof raw.avatarUrl === 'string' && raw.avatarUrl) user.avatarUrl = raw.avatarUrl
	if (typeof raw.email === 'string' && raw.email) user.email = raw.email
	return user
}

/**
 * The verified visitor the platform put on a package-app request, or `null`
 * when the header is absent or malformed. `displayName` is the username (the
 * header carries no display name); `role` is `'user'` for visitors who are not
 * members of the owning org, matching `/-/auth/me`.
 */
export function readPackageAppVisitorHeader(request: Request): PackageAppUser | null {
	const raw = request.headers.get(PACKAGE_APP_VISITOR_HEADER)
	if (!raw) return null
	let value: unknown
	try {
		value = JSON.parse(raw)
	} catch {
		return null
	}
	if (!value || typeof value !== 'object') return null
	const header = value as Record<string, unknown>
	if (typeof header.userId !== 'string' || !header.userId) return null
	const username = typeof header.username === 'string' ? header.username : ''
	return {
		userId: header.userId,
		username,
		displayName: username,
		role: typeof header.role === 'string' && header.role ? header.role : 'user',
	}
}

export function createPackageAppAuth(options: PackageAppAuthOptions = {}): PackageAppAuth {
	const mode: PackageAppAuthMode = options.mode ?? 'optional'
	const scopes = normalizeScopes(options.scopes)
	const configuredOrigin = safeOrigin(options.origin) ?? safeOrigin(options.hostedUrl)
	const identity = resolveIdentity(options)
	const doFetch = options.fetch ?? ((request: Request) => fetch(request))

	function originFor(request: Request | undefined): string {
		if (configuredOrigin) return configuredOrigin
		return request ? new URL(request.url).origin : ''
	}

	function build(
		path: string,
		params: Record<string, string | null | undefined>,
		request: Request | undefined,
	): string {
		const search = new URLSearchParams()
		for (const [key, value] of Object.entries(params)) {
			if (value) search.set(key, value)
		}
		const query = search.toString()
		return `${originFor(request)}${path}${query ? `?${query}` : ''}`
	}

	function loginUrl(input: PackageAppAuthLoginInput = {}): string {
		const scope = normalizeScopes(input.scopes ?? scopes).join(' ')
		return build(
			PACKAGE_APP_AUTH_LOGIN_PATH,
			{
				...identityQueryParams(identity, input.request, configuredOrigin),
				return_to: input.returnTo,
				scope,
			},
			input.request,
		)
	}

	function logoutUrl(input: PackageAppAuthUrlInput = {}): string {
		return build(
			PACKAGE_APP_AUTH_LOGOUT_PATH,
			{
				...identityQueryParams(identity, input.request, configuredOrigin),
				return_to: input.returnTo,
			},
			input.request,
		)
	}

	function meUrl(input: Pick<PackageAppAuthUrlInput, 'request'> = {}): string {
		return build(
			PACKAGE_APP_AUTH_ME_PATH,
			identityQueryParams(identity, input.request, configuredOrigin),
			input.request,
		)
	}

	async function getUser(request: Request): Promise<PackageAppUser | null> {
		if (request.headers.has(PACKAGE_APP_VISITOR_HEADER)) {
			return readPackageAppVisitorHeader(request)
		}
		const cookie = request.headers.get('Cookie')
		if (!cookie) return null
		const response = await doFetch(
			new Request(meUrl({ request }), {
				method: 'GET',
				headers: { Accept: 'application/json', Cookie: cookie },
				redirect: 'manual',
			}),
		)
		if (response.status === 401) return null
		if (!response.ok) {
			throw new PackageAppAuthError(
				`/-/auth/me responded ${response.status}`,
				response.status,
			)
		}
		return parseUser(await response.json())
	}

	function redirectToLogin(
		request: Request,
		input: Omit<PackageAppAuthLoginInput, 'request'> = {},
	): Response {
		const url = new URL(request.url)
		const location = loginUrl({
			...input,
			request,
			returnTo: input.returnTo ?? `${url.pathname}${url.search}`,
		})
		return new Response(null, {
			status: 302,
			headers: { Location: location, 'Cache-Control': 'no-store' },
		})
	}

	async function requireUser(request: Request): Promise<PackageAppUser> {
		const user = await getUser(request)
		if (!user) throw redirectToLogin(request)
		return user
	}

	async function resolveUser(request: Request): Promise<PackageAppUser | null> {
		switch (mode) {
			case 'required':
				return requireUser(request)
			case 'optional':
				return getUser(request)
			default: {
				const unreachable: never = mode
				throw new Error(`Unknown auth mode: ${String(unreachable)}`)
			}
		}
	}

	return {
		mode,
		scopes,
		loginUrl,
		logoutUrl,
		meUrl,
		getUser,
		redirectToLogin,
		requireUser,
		resolveUser,
	}
}