From 178a1c65b5b35ae043e5c63be7d413fb82ec072b Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Fri, 22 May 2026 19:12:40 -0500 Subject: [PATCH 1/3] feat(device): implement OAuth 2.0 Device Authorization Grant --- packages/core/package.json | 2 +- packages/device/CHANGELOG.md | 13 ++ packages/device/README.md | 57 ++++++ packages/device/deno.json | 15 ++ packages/device/package.json | 62 ++++++ packages/device/src/@types/config.ts | 35 ++++ packages/device/src/@types/device.ts | 97 ++++++++++ packages/device/src/@types/index.ts | 12 ++ packages/device/src/@types/session.ts | 11 ++ packages/device/src/actions/authorize.ts | 68 +++++++ packages/device/src/actions/poll.ts | 134 +++++++++++++ packages/device/src/actions/userinfo.ts | 46 +++++ packages/device/src/device-client.ts | 28 +++ packages/device/src/index.ts | 17 ++ packages/device/src/providers/github.ts | 79 ++++++++ packages/device/src/providers/index.ts | 70 +++++++ packages/device/src/schemas.ts | 41 ++++ packages/device/src/shared/constants.ts | 5 + packages/device/src/shared/env.ts | 39 ++++ packages/device/src/shared/errors.ts | 55 ++++++ packages/device/src/shared/fetcher.ts | 20 ++ packages/device/src/shared/form.ts | 9 + packages/device/src/shared/sleep.ts | 3 + packages/device/src/shared/url.ts | 13 ++ .../device/test/actions/authorize.test.ts | 92 +++++++++ packages/device/test/actions/poll.test.ts | 180 ++++++++++++++++++ packages/device/test/providers.test.ts | 49 +++++ packages/device/tsconfig.json | 13 ++ packages/device/tsdown.config.ts | 7 + packages/device/vitest.config.ts | 23 +++ pnpm-lock.yaml | 33 +++- pnpm-workspace.yaml | 2 + 32 files changed, 1321 insertions(+), 9 deletions(-) create mode 100644 packages/device/CHANGELOG.md create mode 100644 packages/device/README.md create mode 100644 packages/device/deno.json create mode 100644 packages/device/package.json create mode 100644 packages/device/src/@types/config.ts create mode 100644 packages/device/src/@types/device.ts create mode 100644 packages/device/src/@types/index.ts create mode 100644 packages/device/src/@types/session.ts create mode 100644 packages/device/src/actions/authorize.ts create mode 100644 packages/device/src/actions/poll.ts create mode 100644 packages/device/src/actions/userinfo.ts create mode 100644 packages/device/src/device-client.ts create mode 100644 packages/device/src/index.ts create mode 100644 packages/device/src/providers/github.ts create mode 100644 packages/device/src/providers/index.ts create mode 100644 packages/device/src/schemas.ts create mode 100644 packages/device/src/shared/constants.ts create mode 100644 packages/device/src/shared/env.ts create mode 100644 packages/device/src/shared/errors.ts create mode 100644 packages/device/src/shared/fetcher.ts create mode 100644 packages/device/src/shared/form.ts create mode 100644 packages/device/src/shared/sleep.ts create mode 100644 packages/device/src/shared/url.ts create mode 100644 packages/device/test/actions/authorize.test.ts create mode 100644 packages/device/test/actions/poll.test.ts create mode 100644 packages/device/test/providers.test.ts create mode 100644 packages/device/tsconfig.json create mode 100644 packages/device/tsdown.config.ts create mode 100644 packages/device/vitest.config.ts 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..e4f0bb32 --- /dev/null +++ b/packages/device/package.json @@ -0,0 +1,62 @@ +{ + "name": "@aura-stack/device", + "version": "0.0.0", + "private": true, + "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..031b1d5f --- /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)[] +} + +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..4e0ebd97 --- /dev/null +++ b/packages/device/src/actions/authorize.ts @@ -0,0 +1,68 @@ +import { safeParse } from "valibot" +import { fetcher } from "@/shared/fetcher.ts" +import { formHeaders, toFormBody } from "@/shared/form.ts" +import { DeviceAuthError, DeviceOAuthError } from "@/shared/errors.ts" +import { resolveScope, resolveUrl } 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 = resolveUrl(deviceConfig.deviceAuthorization) + const scope = resolveScope(deviceConfig.deviceAuthorization, deviceConfig.scope) + const bodyParams: Record = { + client_id: deviceConfig.clientId, + } + if (scope) { + bodyParams.scope = scope + } + + const response = await fetcher(url, { + method: "POST", + headers: formHeaders(), + body: toFormBody(bodyParams), + }) + + const json = await response.json() + if (!response.ok) { + const error = typeof json === "object" && json !== null && "error" in json ? String((json as { error: string }).error) : "server_error" + const description = + typeof json === "object" && json !== null && "error_description" in json + ? String((json as { error_description: string }).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 result: 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 result + } +} diff --git a/packages/device/src/actions/poll.ts b/packages/device/src/actions/poll.ts new file mode 100644 index 00000000..9278555b --- /dev/null +++ b/packages/device/src/actions/poll.ts @@ -0,0 +1,134 @@ +import { safeParse } from "valibot" +import { fetcher } from "@/shared/fetcher.ts" +import { formHeaders, toFormBody } from "@/shared/form.ts" +import { getUserInfo } from "@/actions/userinfo.ts" +import { DeviceAuthError, DeviceOAuthError } from "@/shared/errors.ts" +import { resolveUrl } 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 = resolveUrl(provider.accessToken) + let intervalMs = initialIntervalMs + + while (Date.now() < deadline) { + const response = await fetcher(tokenURL, { + method: "POST", + headers: formHeaders(), + body: toFormBody({ + grant_type: DEVICE_CODE_GRANT, + device_code: deviceCode, + client_id: provider.clientId, + }), + }) + + const json = await response.json() + 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 + } + 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}).` + throw new DeviceOAuthError("server_error", message) + } + + const tokenResult = safeParse(OAuthDeviceAccessTokenResponse, json) + if (!tokenResult.success) { + throw new DeviceOAuthError("invalid_request", "Failed to parse device token response") + } + + const { access_token, token_type, expires_in, refresh_token, scope } = tokenResult.output + const user = await getUserInfo(provider, access_token) + + context.setPending?.(null) + + return { + accessToken: access_token, + tokenType: token_type, + expiresIn: expires_in, + refreshToken: refresh_token, + scope, + user, + } + } + + 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..fa287159 --- /dev/null +++ b/packages/device/src/actions/userinfo.ts @@ -0,0 +1,46 @@ +import { fetcher } from "@/shared/fetcher.ts" +import { resolveUrl } from "@/shared/url.ts" +import { DeviceOAuthError } from "@/shared/errors.ts" +import type { DeviceProviderCredentials } from "@/@types/device.ts" +import type { User } from "@/@types/session.ts" + +const getDefaultUser = (profile: Record): User => { + const sub = + profile.id?.toString() ?? + (profile.sub as string | undefined) ?? + (profile.uid as string | undefined) ?? + (profile.user_id as string | undefined) + if (!sub) { + throw new DeviceOAuthError("invalid_request", "OAuth provider did not return a stable user identifier (id/sub/uid).") + } + return { + sub, + email: (profile.email as string | null | undefined) ?? null, + name: (profile.name as string | null | undefined) ?? (profile.username as string | null | undefined) ?? null, + image: (profile.image as string | null | undefined) ?? (profile.picture as string | null | undefined) ?? null, + } +} + +export const getUserInfo = async ( + provider: DeviceProviderCredentials, DefaultUser>, + accessToken: string +): Promise => { + const userinfoURL = resolveUrl(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}).`) + } + + const profile = (await response.json()) as Record + if (provider.profile) { + return provider.profile(profile) + } + return getDefaultUser(profile) as DefaultUser +} diff --git a/packages/device/src/device-client.ts b/packages/device/src/device-client.ts new file mode 100644 index 00000000..e656f08f --- /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 { createBuiltInOAuthProviders } 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 = createBuiltInOAuthProviders(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..591eef3c --- /dev/null +++ b/packages/device/src/index.ts @@ -0,0 +1,17 @@ +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..f0ce55ad --- /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 + 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 + * createBuiltInOAuthProviders(["github"]) + * + * // Using built-in provider with explicit credentials via factory + * createBuiltInOAuthProviders([github({ clientId: "...", deviceAuthorization: { ...} })]) + */ +export const createBuiltInOAuthProviders = (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..dc849ed9 --- /dev/null +++ b/packages/device/src/schemas.ts @@ -0,0 +1,41 @@ +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.union([valibot.pipe(valibot.string(), valibot.url())]), + userInfo: valibot.union([valibot.pipe(valibot.string(), valibot.url())]), + clientId: valibot.pipe(valibot.string(), valibot.minLength(1)), + scope: valibot.optional(valibot.string()), +}) + +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..2a4f860c --- /dev/null +++ b/packages/device/src/shared/constants.ts @@ -0,0 +1,5 @@ +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..532b831b --- /dev/null +++ b/packages/device/src/shared/errors.ts @@ -0,0 +1,55 @@ +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..a6af1e4f --- /dev/null +++ b/packages/device/src/shared/fetcher.ts @@ -0,0 +1,20 @@ +/** + * 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 + * @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 response = await fetch(url, { + ...options, + signal: controller.signal, + }).finally(() => clearTimeout(timeoutId)) + return response +} diff --git a/packages/device/src/shared/form.ts b/packages/device/src/shared/form.ts new file mode 100644 index 00000000..eeb8a980 --- /dev/null +++ b/packages/device/src/shared/form.ts @@ -0,0 +1,9 @@ +export const toFormBody = (params: Record): URLSearchParams => { + return new URLSearchParams(params) +} + +export const formHeaders = (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..e675237e --- /dev/null +++ b/packages/device/src/shared/url.ts @@ -0,0 +1,13 @@ +export const resolveUrl = (config: string | { url: string }): string => { + return typeof config === "string" ? config : config.url +} + +export const resolveScope = ( + 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..dd2ff62a --- /dev/null +++ b/packages/device/test/actions/authorize.test.ts @@ -0,0 +1,92 @@ +import { describe, test, expect, vi, afterEach } from "vitest" +import { authorize } from "@/actions/authorize.ts" +import { DeviceAuthError, DeviceOAuthError } from "@/shared/errors.ts" +import type { DeviceProviderCredentials } from "@/@types/device.ts" + +const githubProvider: DeviceProviderCredentials = { + id: "github", + name: "GitHub", + clientId: "test-client-id", + 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", +} + +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.unstubAllGlobals() +}) + +describe("authorize", () => { + test("POSTs client_id and scope to device authorization endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(deviceAuthResponse), { status: 200 }) + ) + vi.stubGlobal("fetch", fetchMock) + + const setPending = vi.fn() + const authorizeFn = authorize({ + providers: { github: githubProvider }, + setPending, + }) + + const result = await authorizeFn("github") + + expect(fetchMock).toHaveBeenCalledOnce() + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe("https://github.com/login/device/code") + expect(init?.method).toBe("POST") + expect(init?.headers).toMatchObject({ + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }) + expect(init?.body?.toString()).toBe("client_id=test-client-id&scope=read%3Auser+user%3Aemail") + + expect(result).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("throws DeviceAuthError when provider is not found", async () => { + const authorizeFn = authorize({ providers: { github: githubProvider } }) + await expect(authorizeFn("unknown" as "github")).rejects.toThrow(DeviceAuthError) + }) + + test("throws DeviceOAuthError on HTTP error response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "invalid_client", error_description: "Bad client" }), { + status: 401, + }) + ) + ) + + const authorizeFn = authorize({ providers: { github: githubProvider } }) + 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..22474bef --- /dev/null +++ b/packages/device/test/actions/poll.test.ts @@ -0,0 +1,180 @@ +import { describe, test, expect, vi, afterEach, beforeEach } from "vitest" +import { poll } from "@/actions/poll.ts" +import { authorize } from "@/actions/authorize.ts" +import { DeviceAuthError, DeviceOAuthError } from "@/shared/errors.ts" +import type { DeviceProviderCredentials } from "@/@types/device.ts" +import type { PendingDeviceAuth } from "@/@types/config.ts" + +const githubProvider: DeviceProviderCredentials = { + id: "github", + name: "GitHub", + clientId: "test-client-id", + deviceAuthorization: { + url: "https://github.com/login/device/code", + params: { scope: "read:user" }, + }, + accessToken: "https://github.com/login/oauth/access_token", + userInfo: "https://api.github.com/user", + profile: (profile: Record) => ({ + sub: String(profile.id), + name: String(profile.login), + }), +} + +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( + new Response(JSON.stringify({ error: "authorization_pending" }), { status: 400 }) + ) + .mockResolvedValueOnce(new Response(JSON.stringify(tokenSuccess), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(userProfile), { status: 200 })) + 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) + const [tokenUrl, tokenInit] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(tokenUrl).toBe("https://github.com/login/oauth/access_token") + expect(tokenInit?.body?.toString()).toContain("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code") + expect(tokenInit?.body?.toString()).toContain("device_code=device-code-123") + expect(tokenInit?.body?.toString()).toContain("client_id=test-client-id") + }) + + test("accepts explicit providerId and deviceCode", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(tokenSuccess), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(userProfile), { status: 200 })) + ) + + const pollFn = poll({ providers: { github: githubProvider } }) + const session = await pollFn({ + providerId: "github", + deviceCode: "explicit-device-code", + interval: 5, + timeout: 60_000, + }) + + 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) + await expect(pollFn({ interval: 0.001 })).rejects.toThrow(DeviceOAuthError) + }) + + test("increases interval on slow_down", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ error: "slow_down" }), { status: 400 })) + .mockResolvedValueOnce(new Response(JSON.stringify(tokenSuccess), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(userProfile), { status: 200 })) + 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..d5a35f99 --- /dev/null +++ b/packages/device/test/providers.test.ts @@ -0,0 +1,49 @@ +import { describe, test, expect, vi, afterEach } from "vitest" +import { createBuiltInOAuthProviders } from "@/providers/index.ts" +import { DeviceProviderCredentials } from "@/@types/device.ts" + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe("createBuiltInOAuthProviders", () => { + test("create github provider configuration from environment variables", () => { + vi.stubEnv("GITHUB_CLIENT_ID", "test-client-id") + const providers = createBuiltInOAuthProviders(["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(() => createBuiltInOAuthProviders(["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(() => + createBuiltInOAuthProviders([ + { + 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..a8759ae9 --- /dev/null +++ b/packages/device/vitest.config.ts @@ -0,0 +1,23 @@ +import path from "path" +import { defineConfig } from "vitest/config" + +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/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 From 192390259f41fc14c154e3d75f8a81631ec94923 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sat, 23 May 2026 08:51:48 -0500 Subject: [PATCH 2/3] refactor(device): clean up code --- packages/device/package.json | 2 +- packages/device/src/@types/config.ts | 4 +- packages/device/src/actions/authorize.ts | 21 +++-- packages/device/src/actions/poll.ts | 36 +++++--- packages/device/src/actions/userinfo.ts | 30 ++----- packages/device/src/device-client.ts | 4 +- packages/device/src/index.ts | 8 +- packages/device/src/providers/index.ts | 6 +- packages/device/src/schemas.ts | 5 +- packages/device/src/shared/constants.ts | 4 + packages/device/src/shared/errors.ts | 5 +- packages/device/src/shared/fetcher.ts | 15 ++++ packages/device/src/shared/form.ts | 9 -- packages/device/src/shared/url.ts | 4 +- .../device/test/actions/authorize.test.ts | 82 ++++++++++------- packages/device/test/actions/poll.test.ts | 88 ++++++++++--------- packages/device/test/providers.test.ts | 10 +-- packages/device/vitest.config.ts | 3 + 18 files changed, 182 insertions(+), 154 deletions(-) delete mode 100644 packages/device/src/shared/form.ts diff --git a/packages/device/package.json b/packages/device/package.json index e4f0bb32..928305cb 100644 --- a/packages/device/package.json +++ b/packages/device/package.json @@ -1,7 +1,7 @@ { "name": "@aura-stack/device", "version": "0.0.0", - "private": true, + "private": false, "type": "module", "description": "OAuth 2.0 Device Authorization Grant for the Aura Stack ecosystem", "scripts": { diff --git a/packages/device/src/@types/config.ts b/packages/device/src/@types/config.ts index 031b1d5f..0757e67d 100644 --- a/packages/device/src/@types/config.ts +++ b/packages/device/src/@types/config.ts @@ -4,7 +4,7 @@ import type { BuiltInDeviceProvider } from "@/providers/index.ts" import type { DeviceAuthorizationResponse, DeviceProviderCredentials, DeviceSession } from "@/@types/device.ts" export interface DeviceClientOptions { - providers: (BuiltInDeviceProvider | DeviceProviderCredentials)[] + providers: (BuiltInDeviceProvider | DeviceProviderCredentials, DefaultUser>)[] } export interface PollOptions { @@ -29,7 +29,7 @@ export interface PendingDeviceAuth { } export interface AppContext { - providers: Record, DeviceProviderCredentials> + providers: Record, DeviceProviderCredentials>> getPending?: () => PendingDeviceAuth | null setPending?: (pending: PendingDeviceAuth | null) => void } diff --git a/packages/device/src/actions/authorize.ts b/packages/device/src/actions/authorize.ts index 4e0ebd97..a483056c 100644 --- a/packages/device/src/actions/authorize.ts +++ b/packages/device/src/actions/authorize.ts @@ -1,8 +1,8 @@ import { safeParse } from "valibot" import { fetcher } from "@/shared/fetcher.ts" -import { formHeaders, toFormBody } from "@/shared/form.ts" +import { toHeaders, toFormBody } from "@/shared/fetcher.ts" import { DeviceAuthError, DeviceOAuthError } from "@/shared/errors.ts" -import { resolveScope, resolveUrl } from "@/shared/url.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" @@ -15,8 +15,8 @@ export const authorize = (context: AppContext) => { throw new DeviceAuthError("INVALID_PROVIDER", `Provider with id ${providerId} not found`) } - const url = resolveUrl(deviceConfig.deviceAuthorization) - const scope = resolveScope(deviceConfig.deviceAuthorization, deviceConfig.scope) + const url = getResolvedURL(deviceConfig.deviceAuthorization) + const scope = getResolvedScope(deviceConfig.deviceAuthorization, deviceConfig.scope) const bodyParams: Record = { client_id: deviceConfig.clientId, } @@ -26,16 +26,16 @@ export const authorize = (context: AppContext) => { const response = await fetcher(url, { method: "POST", - headers: formHeaders(), + headers: toHeaders(), body: toFormBody(bodyParams), }) - const json = await response.json() + const json = await response.json().catch(() => null) if (!response.ok) { - const error = typeof json === "object" && json !== null && "error" in json ? String((json as { error: string }).error) : "server_error" + 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 as { error_description: string }).error_description) + ? String(json.error_description) : `Device authorization request failed (${response.status}).` throw new DeviceOAuthError(error as DeviceOAuthError["error"], description) } @@ -46,7 +46,7 @@ export const authorize = (context: AppContext) => { } const interval = output.interval ?? DEFAULT_POLL_INTERVAL_SECONDS - const result: DeviceAuthorizationResponse = { + const authorizationResponse: DeviceAuthorizationResponse = { deviceCode: output.device_code, userCode: output.user_code, verificationURI: output.verification_uri, @@ -62,7 +62,6 @@ export const authorize = (context: AppContext) => { expiresAt: Date.now() + output.expires_in * 1000, } context.setPending?.(pending) - - return result + return authorizationResponse } } diff --git a/packages/device/src/actions/poll.ts b/packages/device/src/actions/poll.ts index 9278555b..5c51c985 100644 --- a/packages/device/src/actions/poll.ts +++ b/packages/device/src/actions/poll.ts @@ -1,15 +1,11 @@ import { safeParse } from "valibot" import { fetcher } from "@/shared/fetcher.ts" -import { formHeaders, toFormBody } from "@/shared/form.ts" +import { toHeaders, toFormBody } from "@/shared/fetcher.ts" import { getUserInfo } from "@/actions/userinfo.ts" import { DeviceAuthError, DeviceOAuthError } from "@/shared/errors.ts" -import { resolveUrl } from "@/shared/url.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 { 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" @@ -70,13 +66,22 @@ export const poll = (context: AppContext) => { throw new DeviceAuthError("INVALID_PROVIDER", `Provider with id ${providerId} not found`) } - const tokenURL = resolveUrl(provider.accessToken) + 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: formHeaders(), + headers: toHeaders(), body: toFormBody({ grant_type: DEVICE_CODE_GRANT, device_code: deviceCode, @@ -84,7 +89,7 @@ export const poll = (context: AppContext) => { }), }) - const json = await response.json() + const json = await response.json().catch(() => null) const errorResult = safeParse(OAuthDeviceTokenErrorResponse, json) if (errorResult.success) { @@ -98,6 +103,7 @@ export const poll = (context: AppContext) => { await sleep(intervalMs) continue } + cleanUpPending() throw new DeviceOAuthError(error, error_description ?? error) } @@ -111,14 +117,14 @@ export const poll = (context: AppContext) => { 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) - context.setPending?.(null) - return { accessToken: access_token, tokenType: token_type, @@ -129,6 +135,10 @@ export const poll = (context: AppContext) => { } } - throw new DeviceAuthError("POLL_TIMEOUT", "Device authorization polling timed out before the user completed authorization.") + 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 index fa287159..7b1c4312 100644 --- a/packages/device/src/actions/userinfo.ts +++ b/packages/device/src/actions/userinfo.ts @@ -1,31 +1,14 @@ import { fetcher } from "@/shared/fetcher.ts" -import { resolveUrl } from "@/shared/url.ts" +import { getResolvedURL } from "@/shared/url.ts" import { DeviceOAuthError } from "@/shared/errors.ts" -import type { DeviceProviderCredentials } from "@/@types/device.ts" import type { User } from "@/@types/session.ts" - -const getDefaultUser = (profile: Record): User => { - const sub = - profile.id?.toString() ?? - (profile.sub as string | undefined) ?? - (profile.uid as string | undefined) ?? - (profile.user_id as string | undefined) - if (!sub) { - throw new DeviceOAuthError("invalid_request", "OAuth provider did not return a stable user identifier (id/sub/uid).") - } - return { - sub, - email: (profile.email as string | null | undefined) ?? null, - name: (profile.name as string | null | undefined) ?? (profile.username as string | null | undefined) ?? null, - image: (profile.image as string | null | undefined) ?? (profile.picture as string | null | undefined) ?? null, - } -} +import type { DeviceProviderCredentials } from "@/@types/device.ts" export const getUserInfo = async ( provider: DeviceProviderCredentials, DefaultUser>, accessToken: string ): Promise => { - const userinfoURL = resolveUrl(provider.userInfo) + const userinfoURL = getResolvedURL(provider.userInfo) const response = await fetcher(userinfoURL, { method: "GET", headers: { @@ -38,9 +21,12 @@ export const getUserInfo = async ( throw new DeviceOAuthError("server_error", `Failed to fetch user information (${response.status}).`) } - const profile = (await response.json()) as Record + const profile = await response.json() if (provider.profile) { return provider.profile(profile) } - return getDefaultUser(profile) as DefaultUser + 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 index e656f08f..f67fdee2 100644 --- a/packages/device/src/device-client.ts +++ b/packages/device/src/device-client.ts @@ -1,6 +1,6 @@ import { poll } from "@/actions/poll.ts" import { authorize } from "@/actions/authorize.ts" -import { createBuiltInOAuthProviders } from "@/providers/index.ts" +import { createBuiltInDeviceProviders } from "@/providers/index.ts" import type { AuthInstance, DeviceClientOptions, PendingDeviceAuth } from "@/@types/config.ts" /** @@ -10,7 +10,7 @@ import type { AuthInstance, DeviceClientOptions, PendingDeviceAuth } from "@/@ty * @returns Client with `authorize` and `poll` methods */ export const createDeviceClient = (config: DeviceClientOptions): AuthInstance => { - const providers = createBuiltInOAuthProviders(config.providers) + const providers = createBuiltInDeviceProviders(config.providers) let pending: PendingDeviceAuth | null = null const context = { diff --git a/packages/device/src/index.ts b/packages/device/src/index.ts index 591eef3c..eb448bce 100644 --- a/packages/device/src/index.ts +++ b/packages/device/src/index.ts @@ -1,13 +1,7 @@ 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 { AuthInstance, DeviceClientOptions, PollOptions, PendingDeviceAuth, AppContext } from "@/@types/config.ts" export type { DeviceAuthorizationResponse, DeviceProviderConfig, diff --git a/packages/device/src/providers/index.ts b/packages/device/src/providers/index.ts index f0ce55ad..94538ef6 100644 --- a/packages/device/src/providers/index.ts +++ b/packages/device/src/providers/index.ts @@ -54,12 +54,12 @@ const defineOAuthProviderConfig = (config: BuiltInDeviceProvider | DeviceProvide * @returns A record of Device provider configurations * @example * // Using built-in provider with env variables - * createBuiltInOAuthProviders(["github"]) + * createBuiltInDeviceProviders(["github"]) * * // Using built-in provider with explicit credentials via factory - * createBuiltInOAuthProviders([github({ clientId: "...", deviceAuthorization: { ...} })]) + * createBuiltInDeviceProviders([github({ clientId: "...", deviceAuthorization: { ...} })]) */ -export const createBuiltInOAuthProviders = (oauth: (BuiltInDeviceProvider | DeviceProviderCredentials)[] = []) => { +export const createBuiltInDeviceProviders = (oauth: (BuiltInDeviceProvider | DeviceProviderCredentials)[] = []) => { return oauth.reduce((previous, config) => { const oauthConfig = defineOAuthProviderConfig(config) if (oauthConfig.id in previous) { diff --git a/packages/device/src/schemas.ts b/packages/device/src/schemas.ts index dc849ed9..0c25ce5d 100644 --- a/packages/device/src/schemas.ts +++ b/packages/device/src/schemas.ts @@ -12,10 +12,11 @@ export const DeviceProviderCredentialsSchema = valibot.object({ id: valibot.string(), name: valibot.string(), deviceAuthorization: DeviceAuthorizationConfigSchema, - accessToken: valibot.union([valibot.pipe(valibot.string(), valibot.url())]), - userInfo: valibot.union([valibot.pipe(valibot.string(), valibot.url())]), + 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({ diff --git a/packages/device/src/shared/constants.ts b/packages/device/src/shared/constants.ts index 2a4f860c..b304906c 100644 --- a/packages/device/src/shared/constants.ts +++ b/packages/device/src/shared/constants.ts @@ -1,3 +1,7 @@ +/** + * 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 diff --git a/packages/device/src/shared/errors.ts b/packages/device/src/shared/errors.ts index 532b831b..ef554d83 100644 --- a/packages/device/src/shared/errors.ts +++ b/packages/device/src/shared/errors.ts @@ -3,7 +3,10 @@ interface V8ErrorConstructor extends ErrorConstructor { } const hasCaptureStackTrace = (errorConstructor: ErrorConstructor): errorConstructor is V8ErrorConstructor => { - return "captureStackTrace" in errorConstructor && typeof (errorConstructor as V8ErrorConstructor).captureStackTrace === "function" + return ( + "captureStackTrace" in errorConstructor && + typeof (errorConstructor as V8ErrorConstructor).captureStackTrace === "function" + ) } export type DeviceAuthErrorCode = "NO_PENDING_AUTHORIZATION" | "INVALID_PROVIDER" | "INVALID_POLL_INPUT" | "POLL_TIMEOUT" diff --git a/packages/device/src/shared/fetcher.ts b/packages/device/src/shared/fetcher.ts index a6af1e4f..e0a1f604 100644 --- a/packages/device/src/shared/fetcher.ts +++ b/packages/device/src/shared/fetcher.ts @@ -5,6 +5,7 @@ * @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); */ @@ -12,9 +13,23 @@ export const fetcher = async (url: string | Request, options: RequestInit = {}, const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), timeout) + if (options.signal) { + options.signal.addEventListener("abort", () => controller.abort()) + } + const response = await fetch(url, { ...options, signal: controller.signal, }).finally(() => clearTimeout(timeoutId)) 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/form.ts b/packages/device/src/shared/form.ts deleted file mode 100644 index eeb8a980..00000000 --- a/packages/device/src/shared/form.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const toFormBody = (params: Record): URLSearchParams => { - return new URLSearchParams(params) -} - -export const formHeaders = (extra?: HeadersInit): HeadersInit => ({ - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - ...extra, -}) diff --git a/packages/device/src/shared/url.ts b/packages/device/src/shared/url.ts index e675237e..9315ba77 100644 --- a/packages/device/src/shared/url.ts +++ b/packages/device/src/shared/url.ts @@ -1,8 +1,8 @@ -export const resolveUrl = (config: string | { url: string }): string => { +export const getResolvedURL = (config: string | { url: string }): string => { return typeof config === "string" ? config : config.url } -export const resolveScope = ( +export const getResolvedScope = ( deviceAuthorization: string | { url: string; params?: { scope?: string } }, providerScope?: string ): string | undefined => { diff --git a/packages/device/test/actions/authorize.test.ts b/packages/device/test/actions/authorize.test.ts index dd2ff62a..565e5638 100644 --- a/packages/device/test/actions/authorize.test.ts +++ b/packages/device/test/actions/authorize.test.ts @@ -1,19 +1,7 @@ import { describe, test, expect, vi, afterEach } from "vitest" import { authorize } from "@/actions/authorize.ts" import { DeviceAuthError, DeviceOAuthError } from "@/shared/errors.ts" -import type { DeviceProviderCredentials } from "@/@types/device.ts" - -const githubProvider: DeviceProviderCredentials = { - id: "github", - name: "GitHub", - clientId: "test-client-id", - 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", -} +import { createBuiltInDeviceProviders } from "@/providers/index.ts" const deviceAuthResponse = { device_code: "device-code-123", @@ -29,31 +17,39 @@ afterEach(() => { }) 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 () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify(deviceAuthResponse), { status: 200 }) - ) + 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: { github: githubProvider }, + providers: createBuiltInDeviceProviders(["github"]), setPending, }) - const result = await authorizeFn("github") + const authorizationResponse = await authorizeFn("github") - expect(fetchMock).toHaveBeenCalledOnce() - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] - expect(url).toBe("https://github.com/login/device/code") - expect(init?.method).toBe("POST") - expect(init?.headers).toMatchObject({ - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", + 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(init?.body?.toString()).toBe("client_id=test-client-id&scope=read%3Auser+user%3Aemail") - expect(result).toEqual({ + expect(authorizationResponse).toEqual({ deviceCode: "device-code-123", userCode: "ABCD-1234", verificationURI: "https://github.com/login/device", @@ -71,22 +67,44 @@ describe("authorize", () => { ) }) + 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 () => { - const authorizeFn = authorize({ providers: { github: githubProvider } }) + 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( - new Response(JSON.stringify({ error: "invalid_client", error_description: "Bad client" }), { - status: 401, - }) + Response.json( + { error: "invalid_client", error_description: "Bad client" }, + { + status: 401, + } + ) ) ) - const authorizeFn = authorize({ providers: { github: githubProvider } }) + 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 index 22474bef..1eb320e4 100644 --- a/packages/device/test/actions/poll.test.ts +++ b/packages/device/test/actions/poll.test.ts @@ -1,25 +1,15 @@ import { describe, test, expect, vi, afterEach, beforeEach } from "vitest" import { poll } from "@/actions/poll.ts" import { authorize } from "@/actions/authorize.ts" -import { DeviceAuthError, DeviceOAuthError } from "@/shared/errors.ts" -import type { DeviceProviderCredentials } from "@/@types/device.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: DeviceProviderCredentials = { - id: "github", - name: "GitHub", +const githubProvider = { clientId: "test-client-id", - deviceAuthorization: { - url: "https://github.com/login/device/code", - params: { scope: "read:user" }, - }, - accessToken: "https://github.com/login/oauth/access_token", - userInfo: "https://api.github.com/user", - profile: (profile: Record) => ({ - sub: String(profile.id), - name: String(profile.login), - }), -} + ...builtInDeviceProviders.github(), +} as DeviceProviderCredentials const tokenSuccess = { access_token: "access-token", @@ -59,11 +49,9 @@ describe("poll", () => { test("polls until token is issued then fetches userinfo", async () => { const fetchMock = vi .fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ error: "authorization_pending" }), { status: 400 }) - ) - .mockResolvedValueOnce(new Response(JSON.stringify(tokenSuccess), { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify(userProfile), { status: 200 })) + .mockResolvedValueOnce(Response.json({ error: "authorization_pending" }, { status: 400 })) + .mockResolvedValueOnce(Response.json(tokenSuccess)) + .mockResolvedValueOnce(Response.json(userProfile)) vi.stubGlobal("fetch", fetchMock) const pollFn = poll(context) @@ -80,30 +68,40 @@ describe("poll", () => { }) expect(fetchMock).toHaveBeenCalledTimes(3) - const [tokenUrl, tokenInit] = fetchMock.mock.calls[0] as [string, RequestInit] - expect(tokenUrl).toBe("https://github.com/login/oauth/access_token") - expect(tokenInit?.body?.toString()).toContain("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code") - expect(tokenInit?.body?.toString()).toContain("device_code=device-code-123") - expect(tokenInit?.body?.toString()).toContain("client_id=test-client-id") + 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 () => { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(new Response(JSON.stringify(tokenSuccess), { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify(userProfile), { status: 200 })) - ) + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json(tokenSuccess)) + .mockResolvedValueOnce(Response.json(userProfile)) + + vi.stubGlobal("fetch", fetchMock) const pollFn = poll({ providers: { github: githubProvider } }) - const session = await pollFn({ + const pollPromise = pollFn({ providerId: "github", deviceCode: "explicit-device-code", interval: 5, - timeout: 60_000, + 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") @@ -123,21 +121,27 @@ describe("poll", () => { 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 }) - ) + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ error: "expired_token", error_description: "Expired" }), { status: 400 }) + ) ) const pollFn = poll(context) - await expect(pollFn({ interval: 0.001 })).rejects.toThrow(DeviceOAuthError) + 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(new Response(JSON.stringify({ error: "slow_down" }), { status: 400 })) - .mockResolvedValueOnce(new Response(JSON.stringify(tokenSuccess), { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify(userProfile), { status: 200 })) + .mockResolvedValueOnce(Response.json({ error: "slow_down" }, { status: 400 })) + .mockResolvedValueOnce(Response.json(tokenSuccess)) + .mockResolvedValueOnce(Response.json(userProfile)) vi.stubGlobal("fetch", fetchMock) const pollFn = poll(context) diff --git a/packages/device/test/providers.test.ts b/packages/device/test/providers.test.ts index d5a35f99..6de9527f 100644 --- a/packages/device/test/providers.test.ts +++ b/packages/device/test/providers.test.ts @@ -1,15 +1,15 @@ import { describe, test, expect, vi, afterEach } from "vitest" -import { createBuiltInOAuthProviders } from "@/providers/index.ts" +import { createBuiltInDeviceProviders } from "@/providers/index.ts" import { DeviceProviderCredentials } from "@/@types/device.ts" afterEach(() => { vi.unstubAllEnvs() }) -describe("createBuiltInOAuthProviders", () => { +describe("createBuiltInDeviceProviders", () => { test("create github provider configuration from environment variables", () => { vi.stubEnv("GITHUB_CLIENT_ID", "test-client-id") - const providers = createBuiltInOAuthProviders(["github"]) + const providers = createBuiltInDeviceProviders(["github"]) expect(providers.github).toMatchObject({ id: "github", name: "GitHub", @@ -28,7 +28,7 @@ describe("createBuiltInOAuthProviders", () => { test("throws error for invalid provider configuration", () => { vi.stubEnv("GITHUB_CLIENT_ID", "") - expect(() => createBuiltInOAuthProviders(["github"])).toThrow( + expect(() => createBuiltInDeviceProviders(["github"])).toThrow( /Missing or invalid environment variable for OAuth provider "github": GITHUB_CLIENT_ID/ ) }) @@ -36,7 +36,7 @@ describe("createBuiltInOAuthProviders", () => { test("throws error for invalid provider configuration details", () => { vi.stubEnv("CUSTOM_CLIENT_ID", "test-client-id") expect(() => - createBuiltInOAuthProviders([ + createBuiltInDeviceProviders([ { id: "custom", name: "Custom", diff --git a/packages/device/vitest.config.ts b/packages/device/vitest.config.ts index a8759ae9..494388f4 100644 --- a/packages/device/vitest.config.ts +++ b/packages/device/vitest.config.ts @@ -1,6 +1,9 @@ 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"], From 27061405c01a647bf708f1d1d4c42be2d6aefa6b Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sat, 23 May 2026 10:19:11 -0500 Subject: [PATCH 3/3] chore: apply coderabbit --- packages/device/src/actions/poll.ts | 1 + packages/device/src/actions/userinfo.ts | 8 +++++++- packages/device/src/providers/index.ts | 2 +- packages/device/src/shared/fetcher.ts | 11 +++++++++-- packages/device/test/actions/authorize.test.ts | 1 + packages/elysia/package.json | 2 +- 6 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/device/src/actions/poll.ts b/packages/device/src/actions/poll.ts index 5c51c985..4fc26a46 100644 --- a/packages/device/src/actions/poll.ts +++ b/packages/device/src/actions/poll.ts @@ -112,6 +112,7 @@ export const poll = (context: AppContext) => { 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) } diff --git a/packages/device/src/actions/userinfo.ts b/packages/device/src/actions/userinfo.ts index 7b1c4312..8785563e 100644 --- a/packages/device/src/actions/userinfo.ts +++ b/packages/device/src/actions/userinfo.ts @@ -21,7 +21,13 @@ export const getUserInfo = async ( throw new DeviceOAuthError("server_error", `Failed to fetch user information (${response.status}).`) } - const profile = await response.json() + 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) } diff --git a/packages/device/src/providers/index.ts b/packages/device/src/providers/index.ts index 94538ef6..b73907f9 100644 --- a/packages/device/src/providers/index.ts +++ b/packages/device/src/providers/index.ts @@ -34,7 +34,7 @@ const defineOAuthProviderConfig = (config: BuiltInDeviceProvider | DeviceProvide } return { ...oauthConfig, ...output } as DeviceProviderCredentials } - const hasCredentials = config.clientId + 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) { diff --git a/packages/device/src/shared/fetcher.ts b/packages/device/src/shared/fetcher.ts index e0a1f604..ff58c875 100644 --- a/packages/device/src/shared/fetcher.ts +++ b/packages/device/src/shared/fetcher.ts @@ -12,15 +12,22 @@ 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) { - options.signal.addEventListener("abort", () => controller.abort()) + 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)) + }).finally(() => { + clearTimeout(timeoutId) + options.signal?.removeEventListener("abort", onExternalAbort) + }) return response } diff --git a/packages/device/test/actions/authorize.test.ts b/packages/device/test/actions/authorize.test.ts index 565e5638..ed832f89 100644 --- a/packages/device/test/actions/authorize.test.ts +++ b/packages/device/test/actions/authorize.test.ts @@ -13,6 +13,7 @@ const deviceAuthResponse = { } afterEach(() => { + vi.unstubAllEnvs() vi.unstubAllGlobals() }) 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 +}