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**
+
+[](https://www.npmjs.com/package/@aura-stack/device)
+[](https://jsr.io/@aura-stack/device)
+[](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