diff --git a/packages/core/package.json b/packages/core/package.json index 78ca8d8f..368768d2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -89,7 +89,7 @@ "@aura-stack/router": "^0.7.0", "arktype": "^2.2.0", "typebox": "^1.1.38", - "valibot": "^1.3.1", + "valibot": "catalog:valibot", "zod": "catalog:zod-v4" }, "devDependencies": { diff --git a/packages/device/CHANGELOG.md b/packages/device/CHANGELOG.md new file mode 100644 index 00000000..e826ca86 --- /dev/null +++ b/packages/device/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog - `@aura-stack/device` + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [Unreleased] + +### Added + +### Changed diff --git a/packages/device/README.md b/packages/device/README.md new file mode 100644 index 00000000..d9231efb --- /dev/null +++ b/packages/device/README.md @@ -0,0 +1,57 @@ +
+ +

@aura-stack/device

+ +**OAuth 2.0 Device Authorization Grant for the Aura Stack ecosystem** + +[![npm version](https://img.shields.io/npm/v/@aura-stack/device.svg)](https://www.npmjs.com/package/@aura-stack/device) +[![JSR version](https://jsr.io/badges/@aura-stack/device)](https://jsr.io/@aura-stack/device) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) + +[Official Docs](https://aura-stack-auth.vercel.app/docs) · [Core Package Docs](https://aura-stack-auth.vercel.app/docs/packages/core) + +
+ +## Quick start + +```ts +import { createDeviceClient } from "@aura-stack/device" + +const client = createDeviceClient({ providers: ["github"] }) + +const { userCode, verificationURI } = await client.authorize("github") +console.log(`Visit ${verificationURI} and enter ${userCode}`) + +const session = await client.poll() +console.log(session.user) +``` + +Poll with explicit credentials: + +```ts +const session = await client.poll({ + providerId: "github", + deviceCode: "...", + timeout: 600_000, +}) +``` + +## Installation + +```bash +pnpm add @aura-stack/device +``` + +## Documentation + +Visit the [**official documentation website**](https://aura-stack-auth.vercel.app). + +## License + +Licensed under the [MIT License](../../LICENSE). © [Aura Stack](https://github.com/aura-stack-ts) + +--- + +

+ Made with ❤️ by Aura Stack team +

diff --git a/packages/device/deno.json b/packages/device/deno.json new file mode 100644 index 00000000..826d87f0 --- /dev/null +++ b/packages/device/deno.json @@ -0,0 +1,15 @@ +{ + "name": "@aura-stack/device", + "version": "0.0.0", + "license": "MIT", + "tasks": { + "dev": "deno run --watch src/index.ts" + }, + "imports": { + "@/": "./src/" + }, + "publish": { + "include": ["src/**/*.ts", "src/**/*.tsx", "README.md", "CHANGELOG.md"] + }, + "exclude": ["dist", "node_modules"] +} diff --git a/packages/device/package.json b/packages/device/package.json new file mode 100644 index 00000000..928305cb --- /dev/null +++ b/packages/device/package.json @@ -0,0 +1,62 @@ +{ + "name": "@aura-stack/device", + "version": "0.0.0", + "private": false, + "type": "module", + "description": "OAuth 2.0 Device Authorization Grant for the Aura Stack ecosystem", + "scripts": { + "dev": "tsdown --watch", + "build": " tsdown", + "lint": "oxlint", + "lint:fix": "oxlint --fix", + "test": "vitest --run", + "test:watch": "vitest", + "test:coverage": "vitest --run --coverage", + "format": "oxfmt", + "format:check": "oxfmt --check", + "type-check": "tsc --noEmit", + "clean:cts": "if [ -d dist ]; then find dist -type f -name \"*.cts\" -delete; fi", + "prepublishOnly": "pnpm clean:cts && pnpm build && pnpm clean:cts" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/aura-stack-ts/auth" + }, + "sideEffects": false, + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./providers": { + "types": "./dist/providers/index.d.ts", + "import": "./dist/providers/index.js", + "require": "./dist/providers/index.cjs" + }, + "./providers/*": { + "types": "./dist/providers/*.d.ts", + "import": "./dist/providers/*.js", + "require": "./dist/providers/*.cjs" + } + }, + "keywords": [], + "author": "Aura Stack | Hernan Alvarado ", + "homepage": "https://aura-stack-auth.vercel.app", + "bugs": { + "url": "https://github.com/aura-stack-ts/auth/issues" + }, + "license": "MIT", + "dependencies": { + "valibot": "catalog:valibot" + }, + "devDependencies": { + "@aura-stack/tsconfig": "workspace:*", + "@aura-stack/tsdown-config": "workspace:*" + }, + "peerDependencies": {}, + "packageManager": "pnpm@10.15.0" +} diff --git a/packages/device/src/@types/config.ts b/packages/device/src/@types/config.ts new file mode 100644 index 00000000..0757e67d --- /dev/null +++ b/packages/device/src/@types/config.ts @@ -0,0 +1,35 @@ +import type { User } from "@/@types/session.ts" +import type { LiteralUnion } from "@/@types/index.ts" +import type { BuiltInDeviceProvider } from "@/providers/index.ts" +import type { DeviceAuthorizationResponse, DeviceProviderCredentials, DeviceSession } from "@/@types/device.ts" + +export interface DeviceClientOptions { + providers: (BuiltInDeviceProvider | DeviceProviderCredentials, DefaultUser>)[] +} + +export interface PollOptions { + /** Maximum time in milliseconds to wait for authorization. */ + timeout?: number + providerId?: LiteralUnion + deviceCode?: string + /** Minimum seconds between poll requests (overrides server interval). */ + interval?: number +} + +export interface AuthInstance { + authorize(providerId: LiteralUnion): Promise + poll(options?: PollOptions): Promise +} + +export interface PendingDeviceAuth { + providerId: LiteralUnion + deviceCode: string + interval: number + expiresAt: number +} + +export interface AppContext { + providers: Record, DeviceProviderCredentials>> + getPending?: () => PendingDeviceAuth | null + setPending?: (pending: PendingDeviceAuth | null) => void +} diff --git a/packages/device/src/@types/device.ts b/packages/device/src/@types/device.ts new file mode 100644 index 00000000..709bdb7d --- /dev/null +++ b/packages/device/src/@types/device.ts @@ -0,0 +1,97 @@ +import type { User } from "@/@types/session.ts" + +export type DeviceAuthorizationConfig = string | { url: string; params?: { scope?: string } } + +export interface DeviceProviderConfig, DefaultUser = User> { + /** + * Default OAuth scope when not specified on the device authorization endpoint params. + */ + scope?: string + /** + * A unique identifier for the device provider, used for logging and debugging purposes. + */ + id: string + /** + * A human-readable name for the device provider, which may be displayed to users during + * the authentication process. + */ + name: string + /** + * The configuration for the device authorization endpoint, which can be a URL string or + * an object containing the URL and optional parameters such as scope. + */ + deviceAuthorization: DeviceAuthorizationConfig + /** + * The configuration for the token endpoint, which can be a URL string or an object + * containing the URL and optional parameters. + */ + accessToken: string | { url: string } + /** + * The configuration for the user information endpoint, which can be a URL string or an object + * containing the URL and optional parameters. + */ + userInfo: string | { url: string } + /** + * An optional function that takes the user profile returned from the user information endpoint + * and transforms it into a DefaultUser object. This allows for customization of the user data + * structure based on the specific requirements of the application. + */ + profile?: (profile: Profile) => DefaultUser | Promise +} + +export interface DeviceProviderCredentials< + Profile extends object = Record, + DefaultUser extends User = User, +> extends DeviceProviderConfig { + clientId: string +} + +/** + * Device Authorization Response as per RFC 8628 + * @see https://datatracker.ietf.org/doc/html/rfc8628#section-3.2 + * + * @example + * { + * "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS", + * "user_code": "WDJB-MJHT", + * "verification_uri": "https://example.com/device", + * "verification_uri_complete": "https://example.com/device?user_code=WDJB-MJHT", + * "expires_in": 1800, + * "interval": 5 + * } + */ +export interface DeviceAuthorizationResponse { + /** + * The device verification code. + */ + deviceCode: string + /** + * The end-user verification code. + */ + userCode: string + /** + * The end-user verification URI on the authorization server. + */ + verificationURI: string + /** + * The end-user verification URI with the user code embedded, per the authorization server's instructions. + */ + verificationURIComplete?: string + /** + * The lifetime in seconds of the device code and the user code. + */ + expiresIn: number + /** + * The minimum amount of time in seconds that the client SHOULD wait between polling requests to the token endpoint. + */ + interval?: number +} + +export interface DeviceSession { + accessToken: string + tokenType: string + expiresIn?: number + refreshToken?: string + scope?: string + user: DefaultUser +} diff --git a/packages/device/src/@types/index.ts b/packages/device/src/@types/index.ts new file mode 100644 index 00000000..87b60ba5 --- /dev/null +++ b/packages/device/src/@types/index.ts @@ -0,0 +1,12 @@ +export type * from "@/@types/device.ts" +export type * from "@/@types/config.ts" +export type * from "@/@types/session.ts" + +/** Expands intersection types into a single flat object type for readable editor hints. */ +export type Prettify = { [K in keyof T]: T[K] } + +/** + * A string that must be one of the literals in `T`, or any other string (`U`). + * Useful for autocomplete on known keys while still allowing custom values. + */ +export type LiteralUnion = T | (U & Record) diff --git a/packages/device/src/@types/session.ts b/packages/device/src/@types/session.ts new file mode 100644 index 00000000..a731b392 --- /dev/null +++ b/packages/device/src/@types/session.ts @@ -0,0 +1,11 @@ +export interface User { + sub: string + name?: string | null + image?: string | null + email?: string | null +} + +export interface Session { + session: DefaultUser + expires: string +} diff --git a/packages/device/src/actions/authorize.ts b/packages/device/src/actions/authorize.ts new file mode 100644 index 00000000..a483056c --- /dev/null +++ b/packages/device/src/actions/authorize.ts @@ -0,0 +1,67 @@ +import { safeParse } from "valibot" +import { fetcher } from "@/shared/fetcher.ts" +import { toHeaders, toFormBody } from "@/shared/fetcher.ts" +import { DeviceAuthError, DeviceOAuthError } from "@/shared/errors.ts" +import { getResolvedScope, getResolvedURL } from "@/shared/url.ts" +import { DEFAULT_POLL_INTERVAL_SECONDS } from "@/shared/constants.ts" +import { OAuthDeviceAuthorizationResponse } from "@/schemas.ts" +import type { BuiltInDeviceProvider } from "@/providers/index.ts" +import type { AppContext, DeviceAuthorizationResponse, LiteralUnion, PendingDeviceAuth } from "@/@types/index.ts" + +export const authorize = (context: AppContext) => { + return async (providerId: LiteralUnion): Promise => { + const deviceConfig = context.providers[providerId] + if (!deviceConfig) { + throw new DeviceAuthError("INVALID_PROVIDER", `Provider with id ${providerId} not found`) + } + + const url = getResolvedURL(deviceConfig.deviceAuthorization) + const scope = getResolvedScope(deviceConfig.deviceAuthorization, deviceConfig.scope) + const bodyParams: Record = { + client_id: deviceConfig.clientId, + } + if (scope) { + bodyParams.scope = scope + } + + const response = await fetcher(url, { + method: "POST", + headers: toHeaders(), + body: toFormBody(bodyParams), + }) + + const json = await response.json().catch(() => null) + if (!response.ok) { + const error = typeof json === "object" && json !== null && "error" in json ? String(json.error) : "server_error" + const description = + typeof json === "object" && json !== null && "error_description" in json + ? String(json.error_description) + : `Device authorization request failed (${response.status}).` + throw new DeviceOAuthError(error as DeviceOAuthError["error"], description) + } + + const { success, output } = safeParse(OAuthDeviceAuthorizationResponse, json) + if (!success) { + throw new DeviceOAuthError("invalid_request", "Failed to parse device authorization response") + } + + const interval = output.interval ?? DEFAULT_POLL_INTERVAL_SECONDS + const authorizationResponse: DeviceAuthorizationResponse = { + deviceCode: output.device_code, + userCode: output.user_code, + verificationURI: output.verification_uri, + verificationURIComplete: output.verification_uri_complete, + expiresIn: output.expires_in, + interval, + } + + const pending: PendingDeviceAuth = { + providerId, + deviceCode: output.device_code, + interval, + expiresAt: Date.now() + output.expires_in * 1000, + } + context.setPending?.(pending) + return authorizationResponse + } +} diff --git a/packages/device/src/actions/poll.ts b/packages/device/src/actions/poll.ts new file mode 100644 index 00000000..4fc26a46 --- /dev/null +++ b/packages/device/src/actions/poll.ts @@ -0,0 +1,145 @@ +import { safeParse } from "valibot" +import { fetcher } from "@/shared/fetcher.ts" +import { toHeaders, toFormBody } from "@/shared/fetcher.ts" +import { getUserInfo } from "@/actions/userinfo.ts" +import { DeviceAuthError, DeviceOAuthError } from "@/shared/errors.ts" +import { getResolvedURL } from "@/shared/url.ts" +import { sleep } from "@/shared/sleep.ts" +import { DEFAULT_POLL_INTERVAL_SECONDS, DEVICE_CODE_GRANT, SLOW_DOWN_INTERVAL_INCREMENT_SECONDS } from "@/shared/constants.ts" +import { OAuthDeviceAccessTokenResponse, OAuthDeviceTokenErrorResponse } from "@/schemas.ts" +import type { BuiltInDeviceProvider } from "@/providers/index.ts" +import type { AppContext, DeviceSession, LiteralUnion, PollOptions } from "@/@types/index.ts" + +const DEFAULT_EXPLICIT_POLL_TIMEOUT_MS = 30 * 60 * 1000 + +interface PollInput { + providerId: LiteralUnion + deviceCode: string + intervalMs: number + deadline: number +} + +const resolvePollInput = (context: AppContext, options?: PollOptions): PollInput => { + if (options?.providerId && options?.deviceCode) { + const provider = context.providers[options.providerId] + if (!provider) { + throw new DeviceAuthError("INVALID_PROVIDER", `Provider with id ${options.providerId} not found`) + } + const intervalMs = (options.interval ?? DEFAULT_POLL_INTERVAL_SECONDS) * 1000 + const deadline = Date.now() + (options.timeout ?? DEFAULT_EXPLICIT_POLL_TIMEOUT_MS) + return { + providerId: options.providerId, + deviceCode: options.deviceCode, + intervalMs, + deadline, + } + } + + if (options?.providerId || options?.deviceCode) { + throw new DeviceAuthError( + "INVALID_POLL_INPUT", + "Both providerId and deviceCode are required when passing explicit poll options." + ) + } + + const pending = context.getPending?.() + if (!pending) { + throw new DeviceAuthError( + "NO_PENDING_AUTHORIZATION", + "No pending device authorization. Call authorize() first or pass providerId and deviceCode to poll()." + ) + } + + return { + providerId: pending.providerId, + deviceCode: pending.deviceCode, + intervalMs: (options?.interval ?? pending.interval) * 1000, + deadline: options?.timeout !== undefined ? Date.now() + options.timeout : pending.expiresAt, + } +} + +export const poll = (context: AppContext) => { + return async (options?: PollOptions): Promise => { + const { providerId, deviceCode, intervalMs: initialIntervalMs, deadline } = resolvePollInput(context, options) + const provider = context.providers[providerId] + if (!provider) { + throw new DeviceAuthError("INVALID_PROVIDER", `Provider with id ${providerId} not found`) + } + + const tokenURL = getResolvedURL(provider.accessToken) + let intervalMs = initialIntervalMs + + await sleep(intervalMs) + + const cleanUpPending = () => { + const pending = context.getPending?.() + if (pending && pending.providerId === providerId && pending.deviceCode === deviceCode) { + context.setPending?.(null) + } + } + + while (Date.now() < deadline) { + const response = await fetcher(tokenURL, { + method: "POST", + headers: toHeaders(), + body: toFormBody({ + grant_type: DEVICE_CODE_GRANT, + device_code: deviceCode, + client_id: provider.clientId, + }), + }) + + const json = await response.json().catch(() => null) + const errorResult = safeParse(OAuthDeviceTokenErrorResponse, json) + + if (errorResult.success) { + const { error, error_description } = errorResult.output + if (error === "authorization_pending") { + await sleep(intervalMs) + continue + } + if (error === "slow_down") { + intervalMs += SLOW_DOWN_INTERVAL_INCREMENT_SECONDS * 1000 + await sleep(intervalMs) + continue + } + cleanUpPending() + throw new DeviceOAuthError(error, error_description ?? error) + } + + if (!response.ok) { + const message = + typeof json === "object" && json !== null && "error_description" in json + ? String((json as { error_description: string }).error_description) + : `Token request failed (${response.status}).` + cleanUpPending() + throw new DeviceOAuthError("server_error", message) + } + + const tokenResult = safeParse(OAuthDeviceAccessTokenResponse, json) + if (!tokenResult.success) { + cleanUpPending() + throw new DeviceOAuthError("invalid_request", "Failed to parse device token response") + } + + const { access_token, token_type, expires_in, refresh_token, scope } = tokenResult.output + cleanUpPending() + const user = await getUserInfo(provider, access_token) + + return { + accessToken: access_token, + tokenType: token_type, + expiresIn: expires_in, + refreshToken: refresh_token, + scope, + user, + } + } + + cleanUpPending() + throw new DeviceAuthError( + "POLL_TIMEOUT", + "Device authorization polling timed out before the user completed authorization." + ) + } +} diff --git a/packages/device/src/actions/userinfo.ts b/packages/device/src/actions/userinfo.ts new file mode 100644 index 00000000..8785563e --- /dev/null +++ b/packages/device/src/actions/userinfo.ts @@ -0,0 +1,38 @@ +import { fetcher } from "@/shared/fetcher.ts" +import { getResolvedURL } from "@/shared/url.ts" +import { DeviceOAuthError } from "@/shared/errors.ts" +import type { User } from "@/@types/session.ts" +import type { DeviceProviderCredentials } from "@/@types/device.ts" + +export const getUserInfo = async ( + provider: DeviceProviderCredentials, DefaultUser>, + accessToken: string +): Promise => { + const userinfoURL = getResolvedURL(provider.userInfo) + const response = await fetcher(userinfoURL, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + throw new DeviceOAuthError("server_error", `Failed to fetch user information (${response.status}).`) + } + + let profile: Record + try { + profile = (await response.json()) as Record + } catch { + throw new DeviceOAuthError("server_error", "Failed to parse user information response.") + } + + if (provider.profile) { + return provider.profile(profile) + } + throw new DeviceOAuthError( + "invalid_request", + "OAuth provider does not have a profile function to parse user information. Please provide a profile function in the provider configuration." + ) +} diff --git a/packages/device/src/device-client.ts b/packages/device/src/device-client.ts new file mode 100644 index 00000000..f67fdee2 --- /dev/null +++ b/packages/device/src/device-client.ts @@ -0,0 +1,28 @@ +import { poll } from "@/actions/poll.ts" +import { authorize } from "@/actions/authorize.ts" +import { createBuiltInDeviceProviders } from "@/providers/index.ts" +import type { AuthInstance, DeviceClientOptions, PendingDeviceAuth } from "@/@types/config.ts" + +/** + * Creates a device authorization client for RFC 8628 OAuth device flows. + * + * @param config - Provider list (built-in names or full provider configs) + * @returns Client with `authorize` and `poll` methods + */ +export const createDeviceClient = (config: DeviceClientOptions): AuthInstance => { + const providers = createBuiltInDeviceProviders(config.providers) + let pending: PendingDeviceAuth | null = null + + const context = { + providers, + getPending: () => pending, + setPending: (value: PendingDeviceAuth | null) => { + pending = value + }, + } + + return { + authorize: authorize(context), + poll: poll(context), + } +} diff --git a/packages/device/src/index.ts b/packages/device/src/index.ts new file mode 100644 index 00000000..eb448bce --- /dev/null +++ b/packages/device/src/index.ts @@ -0,0 +1,11 @@ +export { createDeviceClient } from "@/device-client.ts" +export { DEVICE_CODE_GRANT } from "@/shared/constants.ts" +export { DeviceAuthError, DeviceOAuthError, isDeviceAuthError, isDeviceOAuthError } from "@/shared/errors.ts" +export type { AuthInstance, DeviceClientOptions, PollOptions, PendingDeviceAuth, AppContext } from "@/@types/config.ts" +export type { + DeviceAuthorizationResponse, + DeviceProviderConfig, + DeviceProviderCredentials, + DeviceSession, +} from "@/@types/device.ts" +export type { User, Session } from "@/@types/session.ts" diff --git a/packages/device/src/providers/github.ts b/packages/device/src/providers/github.ts new file mode 100644 index 00000000..6c480824 --- /dev/null +++ b/packages/device/src/providers/github.ts @@ -0,0 +1,79 @@ +import type { User } from "@/@types/session.ts" +import type { DeviceProviderConfig } from "@/@types/device.ts" + +/** + * @see [Get the authenticated user](https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-the-authenticated-user) + */ +export interface GitHubProfile { + login: string + id: number + user_view_type: string + node_id: string + avatar_url: string + gravatar_id: string | null + url: string + html_url: string + followers_url: string + following_url: string + gists_url: string + starred_url: string + subscriptions_url: string + organizations_url: string + repos_url: string + events_url: string + received_events_url: string + type: string + site_admin: boolean + name: string | null + company: string | null + blog: string | null + location: string | null + email: string | null + notification_email: string | null + hireable: boolean | null + bio: string | null + twitter_username?: string | null + public_repos: number + public_gists: number + followers: number + following: number + created_at: string + updated_at: string + private_gists?: number + total_private_repos?: number + owned_private_repos?: number + disk_usage?: number + collaborators?: number + two_factor_authentication: boolean + plan?: { + collaborators: number + name: string + space: number + private_repos: number + } +} + +export const github = ( + options?: Partial> +): DeviceProviderConfig => { + return { + id: "github", + name: "GitHub", + deviceAuthorization: { + url: "https://github.com/login/device/code", + params: { + scope: "read:user user:email", + }, + }, + accessToken: "https://github.com/login/oauth/access_token", + userInfo: "https://api.github.com/user", + profile: (profile) => + ({ + sub: profile.id.toString(), + name: profile.name || profile.login, + image: profile.avatar_url, + email: profile.email, + }) as DefaultUser, + ...options, + } +} diff --git a/packages/device/src/providers/index.ts b/packages/device/src/providers/index.ts new file mode 100644 index 00000000..b73907f9 --- /dev/null +++ b/packages/device/src/providers/index.ts @@ -0,0 +1,70 @@ +import { DeviceProviderCredentials } from "@/@types/device.ts" +import { LiteralUnion } from "@/@types/index.ts" +import { github } from "@/providers/github.ts" +import { DeviceProviderCredentialsSchema } from "@/schemas.ts" +import { getEnv } from "@/shared/env.ts" +import { pick, safeParse } from "valibot" + +export * from "@/providers/github.ts" + +export const builtInDeviceProviders = { + github, +} as const + +export type BuiltInDeviceProvider = keyof typeof builtInDeviceProviders + +const defineOAuthEnvironment = (providerId: string) => { + const clientId = getEnv(`${providerId.replace(/-/g, "_").toUpperCase()}_CLIENT_ID`) + const { success, output } = safeParse(pick(DeviceProviderCredentialsSchema, ["clientId"]), { clientId }) + if (!success) { + throw new Error( + `Missing or invalid environment variable for OAuth provider "${providerId}": ${providerId.replace(/-/g, "_").toUpperCase()}_CLIENT_ID` + ) + } + return output.clientId +} + +const defineOAuthProviderConfig = (config: BuiltInDeviceProvider | DeviceProviderCredentials): DeviceProviderCredentials => { + if (typeof config === "string") { + const clientId = defineOAuthEnvironment(config) + const oauthConfig = builtInDeviceProviders[config]() + const { success, output } = safeParse(DeviceProviderCredentialsSchema, { ...oauthConfig, clientId }) + if (!success) { + throw new Error(`Invalid configuration for OAuth provider "${config}"`) + } + return { ...oauthConfig, ...output } as DeviceProviderCredentials + } + const hasCredentials = config.clientId !== undefined && config.clientId !== null + const envConfig = hasCredentials ? {} : { clientId: defineOAuthEnvironment(config.id) } + const { success, output, issues } = safeParse(DeviceProviderCredentialsSchema, { ...envConfig, ...config }) + if (!success) { + const details = JSON.stringify({ [config.id]: issues }, null, 2) + throw new Error( + `INVALID_OAUTH_PROVIDER_CONFIGURATION: Invalid configuration for OAuth provider "${config.id}": ${details}` + ) + } + return { ...config, ...output } as DeviceProviderCredentials +} + +/** + * Constructs Device provider configurations from an array of provider names or configurations. + * It loads the client ID from environment variables if only the provider name is provided. + * + * @param oauth - Array of Device provider configurations or provider names to be defined from environment variables + * @returns A record of Device provider configurations + * @example + * // Using built-in provider with env variables + * createBuiltInDeviceProviders(["github"]) + * + * // Using built-in provider with explicit credentials via factory + * createBuiltInDeviceProviders([github({ clientId: "...", deviceAuthorization: { ...} })]) + */ +export const createBuiltInDeviceProviders = (oauth: (BuiltInDeviceProvider | DeviceProviderCredentials)[] = []) => { + return oauth.reduce((previous, config) => { + const oauthConfig = defineOAuthProviderConfig(config) + if (oauthConfig.id in previous) { + throw new Error("Duplicate OAuth provider configuration detected for provider ID: " + oauthConfig.id) + } + return { ...previous, [oauthConfig.id]: oauthConfig } + }, {}) as Record, DeviceProviderCredentials> +} diff --git a/packages/device/src/schemas.ts b/packages/device/src/schemas.ts new file mode 100644 index 00000000..0c25ce5d --- /dev/null +++ b/packages/device/src/schemas.ts @@ -0,0 +1,42 @@ +import * as valibot from "valibot" + +export const DeviceAuthorizationConfigSchema = valibot.union([ + valibot.pipe(valibot.string(), valibot.url()), + valibot.object({ + url: valibot.pipe(valibot.string(), valibot.url()), + params: valibot.optional(valibot.object({ scope: valibot.optional(valibot.string()) })), + }), +]) + +export const DeviceProviderCredentialsSchema = valibot.object({ + id: valibot.string(), + name: valibot.string(), + deviceAuthorization: DeviceAuthorizationConfigSchema, + accessToken: valibot.pipe(valibot.string(), valibot.url()), + userInfo: valibot.pipe(valibot.string(), valibot.url()), + clientId: valibot.pipe(valibot.string(), valibot.minLength(1)), + scope: valibot.optional(valibot.string()), + profile: valibot.function(), +}) + +export const OAuthDeviceAuthorizationResponse = valibot.object({ + device_code: valibot.string(), + user_code: valibot.string(), + verification_uri: valibot.string(), + verification_uri_complete: valibot.optional(valibot.string()), + expires_in: valibot.number(), + interval: valibot.optional(valibot.number()), +}) + +export const OAuthDeviceTokenErrorResponse = valibot.object({ + error: valibot.picklist(["authorization_pending", "slow_down", "expired_token", "access_denied"]), + error_description: valibot.optional(valibot.string()), +}) + +export const OAuthDeviceAccessTokenResponse = valibot.object({ + access_token: valibot.string(), + token_type: valibot.string(), + expires_in: valibot.optional(valibot.number()), + refresh_token: valibot.optional(valibot.string()), + scope: valibot.optional(valibot.string()), +}) diff --git a/packages/device/src/shared/constants.ts b/packages/device/src/shared/constants.ts new file mode 100644 index 00000000..b304906c --- /dev/null +++ b/packages/device/src/shared/constants.ts @@ -0,0 +1,9 @@ +/** + * Device Access Token Request + * @see https://datatracker.ietf.org/doc/html/rfc8628#section-3.4 + */ +export const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code" + +export const DEFAULT_POLL_INTERVAL_SECONDS = 5 + +export const SLOW_DOWN_INTERVAL_INCREMENT_SECONDS = 5 diff --git a/packages/device/src/shared/env.ts b/packages/device/src/shared/env.ts new file mode 100644 index 00000000..84bb434d --- /dev/null +++ b/packages/device/src/shared/env.ts @@ -0,0 +1,39 @@ +// @ts-nocheck Ignore type errors for cross-runtime compatibility +/** + * A runtime-agnostic environment variable proxy. + * Checks multiple sources to ensure compatibility with Node, Bun, Deno, Vite, and Edge platforms. + */ +export const env = new Proxy({} as Record, { + get(_, prop: string) { + if (typeof prop !== "string") return undefined + + const hasProperty = (process: Record) => { + return process && Object.prototype.hasOwnProperty.call(process, prop) + } + + try { + if (typeof process !== "undefined" && hasProperty(process.env)) { + return process.env[prop] + } + if (typeof import.meta !== "undefined" && hasProperty(import.meta.env)) { + return import.meta.env[prop] + } + if (typeof Deno !== "undefined" && Deno.env?.get) { + return Deno.env.get(prop) + } + if (typeof Bun !== "undefined" && hasProperty(Bun.env)) { + return Bun.env[prop] + } + const globalValue = (globalThis as Record)[prop] + return typeof globalValue === "string" ? globalValue : undefined + } catch { + return undefined + } + }, +}) + +export const getEnv = (key: string): string | undefined => { + const keys = [`AURA_AUTH_${key.toUpperCase()}`, `AURA_${key.toUpperCase()}`, `AUTH_${key.toUpperCase()}`, key.toUpperCase()] + const found = keys.find((k) => env[k] !== undefined) + return found ? env[found] : undefined +} diff --git a/packages/device/src/shared/errors.ts b/packages/device/src/shared/errors.ts new file mode 100644 index 00000000..ef554d83 --- /dev/null +++ b/packages/device/src/shared/errors.ts @@ -0,0 +1,58 @@ +interface V8ErrorConstructor extends ErrorConstructor { + captureStackTrace(targetObject: object, constructorOpt?: Function): void +} + +const hasCaptureStackTrace = (errorConstructor: ErrorConstructor): errorConstructor is V8ErrorConstructor => { + return ( + "captureStackTrace" in errorConstructor && + typeof (errorConstructor as V8ErrorConstructor).captureStackTrace === "function" + ) +} + +export type DeviceAuthErrorCode = "NO_PENDING_AUTHORIZATION" | "INVALID_PROVIDER" | "INVALID_POLL_INPUT" | "POLL_TIMEOUT" + +export class DeviceAuthError extends Error { + readonly type = "DEVICE_AUTH_ERROR" + readonly code: DeviceAuthErrorCode + + constructor(code: DeviceAuthErrorCode, message?: string, options?: ErrorOptions) { + super(message, options) + this.code = code + this.name = new.target.name + if (hasCaptureStackTrace(Error)) { + Error.captureStackTrace(this, new.target) + } + } +} + +export type DeviceOAuthErrorCode = + | "authorization_pending" + | "slow_down" + | "expired_token" + | "access_denied" + | "invalid_request" + | "server_error" + +export class DeviceOAuthError extends Error { + readonly type = "DEVICE_OAUTH_ERROR" + readonly error: DeviceOAuthErrorCode + readonly errorDescription?: string + + constructor(error: DeviceOAuthErrorCode, description?: string, options?: ErrorOptions) { + super(description, options) + this.error = error + this.errorDescription = description + this.name = new.target.name + if (hasCaptureStackTrace(Error)) { + Error.captureStackTrace(this, new.target) + } + } +} + +export const isDeviceAuthError = (error: unknown): error is DeviceAuthError => { + return error instanceof DeviceAuthError +} + +export const isDeviceOAuthError = (error: unknown): error is DeviceOAuthError => { + return error instanceof DeviceOAuthError +} diff --git a/packages/device/src/shared/fetcher.ts b/packages/device/src/shared/fetcher.ts new file mode 100644 index 00000000..ff58c875 --- /dev/null +++ b/packages/device/src/shared/fetcher.ts @@ -0,0 +1,42 @@ +/** + * Fetches a resource with a timeout mechanism. + * + * @param url - The URL or Request object to fetch + * @param options - Optional RequestInit configuration object + * @param timeout - Timeout duration in milliseconds (default: 5000ms) + * @returns A promise that resolves to the Response object + * @throws {DOMException} Throws AbortError when the timeout is reached + * @example + * const response = await fetcher('https://api.example.com/data', {}, 3000); + */ +export const fetcher = async (url: string | Request, options: RequestInit = {}, timeout: number = 5000) => { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeout) + const onExternalAbort = () => controller.abort() + + if (options.signal) { + if (options.signal.aborted) { + controller.abort() + } + options.signal.addEventListener("abort", onExternalAbort, { once: true }) + } + + const response = await fetch(url, { + ...options, + signal: controller.signal, + }).finally(() => { + clearTimeout(timeoutId) + options.signal?.removeEventListener("abort", onExternalAbort) + }) + return response +} + +export const toFormBody = (params: Record): URLSearchParams => { + return new URLSearchParams(params) +} + +export const toHeaders = (extra?: HeadersInit): HeadersInit => ({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + ...extra, +}) diff --git a/packages/device/src/shared/sleep.ts b/packages/device/src/shared/sleep.ts new file mode 100644 index 00000000..0c87aa46 --- /dev/null +++ b/packages/device/src/shared/sleep.ts @@ -0,0 +1,3 @@ +export const sleep = (ms: number): Promise => { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/packages/device/src/shared/url.ts b/packages/device/src/shared/url.ts new file mode 100644 index 00000000..9315ba77 --- /dev/null +++ b/packages/device/src/shared/url.ts @@ -0,0 +1,13 @@ +export const getResolvedURL = (config: string | { url: string }): string => { + return typeof config === "string" ? config : config.url +} + +export const getResolvedScope = ( + deviceAuthorization: string | { url: string; params?: { scope?: string } }, + providerScope?: string +): string | undefined => { + if (typeof deviceAuthorization === "object" && deviceAuthorization.params?.scope) { + return deviceAuthorization.params.scope + } + return providerScope +} diff --git a/packages/device/test/actions/authorize.test.ts b/packages/device/test/actions/authorize.test.ts new file mode 100644 index 00000000..ed832f89 --- /dev/null +++ b/packages/device/test/actions/authorize.test.ts @@ -0,0 +1,111 @@ +import { describe, test, expect, vi, afterEach } from "vitest" +import { authorize } from "@/actions/authorize.ts" +import { DeviceAuthError, DeviceOAuthError } from "@/shared/errors.ts" +import { createBuiltInDeviceProviders } from "@/providers/index.ts" + +const deviceAuthResponse = { + device_code: "device-code-123", + user_code: "ABCD-1234", + verification_uri: "https://github.com/login/device", + verification_uri_complete: "https://github.com/login/device?user_code=ABCD-1234", + expires_in: 900, + interval: 5, +} + +afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) + +describe("authorize", () => { + test("unsupported provider", async () => { + const authorizeFn = authorize({ providers: createBuiltInDeviceProviders() }) + await expect(authorizeFn("unsupported")).rejects.toThrow(/Provider with id unsupported not found/) + }) + + test("POSTs client_id and scope to device authorization endpoint", async () => { + vi.stubEnv("GITHUB_CLIENT_ID", "test-client-id") + + const fetchMock = vi.fn().mockResolvedValue(Response.json(deviceAuthResponse)) + vi.stubGlobal("fetch", fetchMock) + + const setPending = vi.fn() + const authorizeFn = authorize({ + providers: createBuiltInDeviceProviders(["github"]), + setPending, + }) + + const authorizationResponse = await authorizeFn("github") + + expect(fetchMock).toHaveBeenCalledWith("https://github.com/login/device/code", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: "test-client-id", + scope: "read:user user:email", + }), + signal: expect.any(AbortSignal), + }) + + expect(authorizationResponse).toEqual({ + deviceCode: "device-code-123", + userCode: "ABCD-1234", + verificationURI: "https://github.com/login/device", + verificationURIComplete: "https://github.com/login/device?user_code=ABCD-1234", + expiresIn: 900, + interval: 5, + }) + + expect(setPending).toHaveBeenCalledWith( + expect.objectContaining({ + providerId: "github", + deviceCode: "device-code-123", + interval: 5, + }) + ) + }) + + test("fails to parse JSON response", async () => { + vi.stubEnv("GITHUB_CLIENT_ID", "test-client-id") + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("invalid json", { status: 200 }))) + + const authorizeFn = authorize({ providers: createBuiltInDeviceProviders(["github"]) }) + await expect(authorizeFn("github")).rejects.toThrow(/Failed to parse device authorization response/) + }) + + test("incomplete response missing required fields", async () => { + vi.stubEnv("GITHUB_CLIENT_ID", "test-client-id") + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ device_code: "code" }))) + + const authorizeFn = authorize({ providers: createBuiltInDeviceProviders(["github"]) }) + await expect(authorizeFn("github")).rejects.toThrow(/Failed to parse device authorization response/) + }) + + test("throws DeviceAuthError when provider is not found", async () => { + vi.stubEnv("GITHUB_CLIENT_ID", "test-client-id") + + const authorizeFn = authorize({ providers: createBuiltInDeviceProviders(["github"]) }) + await expect(authorizeFn("unknown" as "github")).rejects.toThrow(DeviceAuthError) + }) + + test("throws DeviceOAuthError on HTTP error response", async () => { + vi.stubEnv("GITHUB_CLIENT_ID", "test-client-id") + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + Response.json( + { error: "invalid_client", error_description: "Bad client" }, + { + status: 401, + } + ) + ) + ) + + const authorizeFn = authorize({ providers: createBuiltInDeviceProviders(["github"]) }) + await expect(authorizeFn("github")).rejects.toThrow(DeviceOAuthError) + }) +}) diff --git a/packages/device/test/actions/poll.test.ts b/packages/device/test/actions/poll.test.ts new file mode 100644 index 00000000..1eb320e4 --- /dev/null +++ b/packages/device/test/actions/poll.test.ts @@ -0,0 +1,184 @@ +import { describe, test, expect, vi, afterEach, beforeEach } from "vitest" +import { poll } from "@/actions/poll.ts" +import { authorize } from "@/actions/authorize.ts" +import { builtInDeviceProviders } from "@/providers/index.ts" +import type { PendingDeviceAuth } from "@/@types/config.ts" +import type { DeviceProviderCredentials } from "@/@types/device.ts" +import { DeviceAuthError, DeviceOAuthError } from "@/shared/errors.ts" + +const githubProvider = { + clientId: "test-client-id", + ...builtInDeviceProviders.github(), +} as DeviceProviderCredentials + +const tokenSuccess = { + access_token: "access-token", + token_type: "bearer", + scope: "read:user", +} + +const userProfile = { id: 42, login: "octocat" } + +let pending: PendingDeviceAuth | null = null + +const context = { + providers: { github: githubProvider }, + getPending: () => pending, + setPending: (value: PendingDeviceAuth | null) => { + pending = value + }, +} + +beforeEach(() => { + pending = { + providerId: "github", + deviceCode: "device-code-123", + interval: 0.001, + expiresAt: Date.now() + 60_000, + } + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + pending = null +}) + +describe("poll", () => { + test("polls until token is issued then fetches userinfo", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json({ error: "authorization_pending" }, { status: 400 })) + .mockResolvedValueOnce(Response.json(tokenSuccess)) + .mockResolvedValueOnce(Response.json(userProfile)) + vi.stubGlobal("fetch", fetchMock) + + const pollFn = poll(context) + const pollPromise = pollFn({ interval: 0.001 }) + + await vi.runAllTimersAsync() + const session = await pollPromise + + expect(session).toEqual({ + accessToken: "access-token", + tokenType: "bearer", + scope: "read:user", + user: { sub: "42", name: "octocat" }, + }) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(fetchMock).toHaveBeenNthCalledWith(1, "https://github.com/login/oauth/access_token", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: "device-code-123", + client_id: "test-client-id", + }), + signal: expect.any(AbortSignal), + }) + }) + + test("accepts explicit providerId and deviceCode", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json(tokenSuccess)) + .mockResolvedValueOnce(Response.json(userProfile)) + + vi.stubGlobal("fetch", fetchMock) + + const pollFn = poll({ providers: { github: githubProvider } }) + const pollPromise = pollFn({ + providerId: "github", + deviceCode: "explicit-device-code", + interval: 5, + timeout: 10_000, + }) + + await vi.runAllTimersAsync() + const session = await pollPromise + + expect(session.user).toEqual({ sub: "42", name: "octocat" }) + const [, tokenInit] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(tokenInit?.body?.toString()).toContain("device_code=explicit-device-code") + }) + + test("throws when neither pending nor explicit inputs are provided", async () => { + pending = null + const pollFn = poll(context) + await expect(pollFn()).rejects.toThrow(DeviceAuthError) + }) + + test("throws when only partial explicit inputs are provided", async () => { + const pollFn = poll(context) + await expect(pollFn({ providerId: "github" })).rejects.toThrow(DeviceAuthError) + }) + + test("throws DeviceOAuthError on expired_token", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ error: "expired_token", error_description: "Expired" }), { status: 400 }) + ) + ) + + const pollFn = poll(context) + const pollPromise = pollFn({ interval: 0.001 }) + const rejection = expect(pollPromise).rejects.toThrow(DeviceOAuthError) + + await vi.runAllTimersAsync() + await rejection + }) + + test("increases interval on slow_down", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json({ error: "slow_down" }, { status: 400 })) + .mockResolvedValueOnce(Response.json(tokenSuccess)) + .mockResolvedValueOnce(Response.json(userProfile)) + vi.stubGlobal("fetch", fetchMock) + + const pollFn = poll(context) + const pollPromise = pollFn({ interval: 1 }) + + await vi.runAllTimersAsync() + await pollPromise + + expect(fetchMock).toHaveBeenCalledTimes(3) + }) +}) + +describe("authorize + poll integration via pending state", () => { + test("authorize sets pending for implicit poll", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + device_code: "dc", + user_code: "UC", + verification_uri: "https://example.com/device", + expires_in: 60, + interval: 1, + }), + { status: 200 } + ) + ) + ) + + const authorizeFn = authorize(context) + await authorizeFn("github") + + expect(pending).toMatchObject({ + providerId: "github", + deviceCode: "dc", + interval: 1, + }) + }) +}) diff --git a/packages/device/test/providers.test.ts b/packages/device/test/providers.test.ts new file mode 100644 index 00000000..6de9527f --- /dev/null +++ b/packages/device/test/providers.test.ts @@ -0,0 +1,49 @@ +import { describe, test, expect, vi, afterEach } from "vitest" +import { createBuiltInDeviceProviders } from "@/providers/index.ts" +import { DeviceProviderCredentials } from "@/@types/device.ts" + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe("createBuiltInDeviceProviders", () => { + test("create github provider configuration from environment variables", () => { + vi.stubEnv("GITHUB_CLIENT_ID", "test-client-id") + const providers = createBuiltInDeviceProviders(["github"]) + expect(providers.github).toMatchObject({ + id: "github", + name: "GitHub", + deviceAuthorization: { + url: "https://github.com/login/device/code", + params: { + scope: "read:user user:email", + }, + }, + accessToken: "https://github.com/login/oauth/access_token", + userInfo: "https://api.github.com/user", + clientId: "test-client-id", + }) + expect(typeof providers.github.profile).toBe("function") + }) + + test("throws error for invalid provider configuration", () => { + vi.stubEnv("GITHUB_CLIENT_ID", "") + expect(() => createBuiltInDeviceProviders(["github"])).toThrow( + /Missing or invalid environment variable for OAuth provider "github": GITHUB_CLIENT_ID/ + ) + }) + + test("throws error for invalid provider configuration details", () => { + vi.stubEnv("CUSTOM_CLIENT_ID", "test-client-id") + expect(() => + createBuiltInDeviceProviders([ + { + id: "custom", + name: "Custom", + accessToken: "invalid-url", + deviceAuthorization: "invalid-url", + } as DeviceProviderCredentials, + ]) + ).toThrow(/Invalid configuration for OAuth provider "custom"/) + }) +}) diff --git a/packages/device/tsconfig.json b/packages/device/tsconfig.json new file mode 100644 index 00000000..4dff45ed --- /dev/null +++ b/packages/device/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@aura-stack/tsconfig/tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "allowImportingTsExtensions": true, + "paths": { + "@/*": ["./src/*"], + "@test/*": ["./test/*"] + } + }, + "include": ["src", "test"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/device/tsdown.config.ts b/packages/device/tsdown.config.ts new file mode 100644 index 00000000..f82e4bc7 --- /dev/null +++ b/packages/device/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "tsdown" +import { tsdownConfig } from "@aura-stack/tsdown-config" + +export default defineConfig({ + ...tsdownConfig, + entry: ["src/index.ts", "src/providers/index.ts", "src/@types/index.ts"], +}) diff --git a/packages/device/vitest.config.ts b/packages/device/vitest.config.ts new file mode 100644 index 00000000..494388f4 --- /dev/null +++ b/packages/device/vitest.config.ts @@ -0,0 +1,26 @@ +import path from "path" +import { fileURLToPath } from "url" +import { defineConfig } from "vitest/config" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + coverage: { + provider: "v8", + enabled: true, + }, + unstubEnvs: true, + typecheck: { + include: ["test/**/*.test-d.ts"], + enabled: false, + }, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "@test": path.resolve(__dirname, "./test"), + }, + }, +}) diff --git a/packages/elysia/package.json b/packages/elysia/package.json index 38b3efbe..ffe9863e 100644 --- a/packages/elysia/package.json +++ b/packages/elysia/package.json @@ -85,4 +85,4 @@ "elysia": ">=1.0.0" }, "packageManager": "pnpm@10.15.0" -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b605a2a..3246bfcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,6 +64,10 @@ catalogs: typescript: specifier: ^5.9.2 version: 5.9.3 + valibot: + valibot: + specifier: ^1.4.0 + version: 1.4.0 vite: vite: specifier: ^7.1.7 @@ -660,7 +664,7 @@ importers: version: link:../jose '@aura-stack/router': specifier: ^0.7.0 - version: 0.7.0(arktype@2.2.0)(typebox@1.1.38)(valibot@1.3.1(typescript@5.9.3))(zod@4.3.5) + version: 0.7.0(arktype@2.2.0)(typebox@1.1.38)(valibot@1.4.0(typescript@5.9.3))(zod@4.3.5) arktype: specifier: ^2.2.0 version: 2.2.0 @@ -668,8 +672,8 @@ importers: specifier: ^1.1.38 version: 1.1.38 valibot: - specifier: ^1.3.1 - version: 1.3.1(typescript@5.9.3) + specifier: catalog:valibot + version: 1.4.0(typescript@5.9.3) zod: specifier: catalog:zod-v4 version: 4.3.5 @@ -687,6 +691,19 @@ importers: specifier: catalog:vitest version: 4.1.4(@types/node@24.12.0)(@vitest/coverage-v8@4.1.4)(jsdom@27.4.0(@noble/hashes@1.8.0))(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2)) + packages/device: + dependencies: + valibot: + specifier: catalog:valibot + version: 1.4.0(typescript@5.9.3) + devDependencies: + '@aura-stack/tsconfig': + specifier: workspace:* + version: link:../../configs/tsconfig + '@aura-stack/tsdown-config': + specifier: workspace:* + version: link:../../configs/tsdown-config + packages/elysia: dependencies: '@aura-stack/auth': @@ -8931,8 +8948,8 @@ packages: typescript: optional: true - valibot@1.3.1: - resolution: {integrity: sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==} + valibot@1.4.0: + resolution: {integrity: sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg==} peerDependencies: typescript: '>=5' peerDependenciesMeta: @@ -9652,13 +9669,13 @@ snapshots: dependencies: yaml: 2.8.2 - '@aura-stack/router@0.7.0(arktype@2.2.0)(typebox@1.1.38)(valibot@1.3.1(typescript@5.9.3))(zod@4.3.5)': + '@aura-stack/router@0.7.0(arktype@2.2.0)(typebox@1.1.38)(valibot@1.4.0(typescript@5.9.3))(zod@4.3.5)': dependencies: cookie: 1.1.1 optionalDependencies: arktype: 2.2.0 typebox: 1.1.38 - valibot: 1.3.1(typescript@5.9.3) + valibot: 1.4.0(typescript@5.9.3) zod: 4.3.5 '@babel/code-frame@7.27.1': @@ -18419,7 +18436,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 - valibot@1.3.1(typescript@5.9.3): + valibot@1.4.0(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c09c6d3c..f3dac8b6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -41,3 +41,5 @@ catalogs: vitest: vitest: 4.1.4 "@vitest/coverage-v8": 4.1.4 + valibot: + valibot: ^1.4.0