|
| 1 | +import type { components } from "../generated/schema.js"; |
| 2 | +import type { Transport, RequestOptions } from "../core/http.js"; |
| 3 | +import { APIError, SessionExpiredError } from "../core/errors.js"; |
| 4 | +import { decodeJwtPayload } from "../core/pkce.js"; |
| 5 | + |
| 6 | +type Schemas = components["schemas"]; |
| 7 | + |
| 8 | +export type DeviceTokens = Schemas["DeviceTokenResponse"]; |
| 9 | +export type RefreshedTokens = Schemas["DeviceRefreshResponse"]; |
| 10 | + |
| 11 | +export interface AuthorizationUrlParams { |
| 12 | + /** Your app's id from the spoo.me connected-apps registry. */ |
| 13 | + appId: string; |
| 14 | + /** |
| 15 | + * Must exactly match the redirect URI registered for the app; the server |
| 16 | + * rejects everything else, including a different port. |
| 17 | + */ |
| 18 | + redirectUri: string; |
| 19 | + /** CSRF-binding state echoed back on the callback. See `generateState()`. */ |
| 20 | + state: string; |
| 21 | + /** The S256 challenge from `generatePkcePair()`. */ |
| 22 | + codeChallenge: string; |
| 23 | +} |
| 24 | + |
| 25 | +export interface TokenProviderOptions { |
| 26 | + /** Current token pair, e.g. from the initial code exchange or app storage. */ |
| 27 | + tokens: { access_token: string; refresh_token: string }; |
| 28 | + /** |
| 29 | + * Called after every successful refresh with the ROTATED pair. Persist it: |
| 30 | + * the previous refresh token is dead the moment this fires. |
| 31 | + */ |
| 32 | + onRefresh?: (tokens: RefreshedTokens) => void | Promise<void>; |
| 33 | + /** Seconds before `exp` to refresh proactively. Default 30. */ |
| 34 | + expirySkew?: number; |
| 35 | +} |
| 36 | + |
| 37 | +/** |
| 38 | + * The client half of Sign in with Spoo (authorization-code + PKCE). The SDK |
| 39 | + * never opens browsers, renders consent, or stores secrets; it provides the |
| 40 | + * protocol pieces and a self-refreshing credential for `new Spoo({ token })`. |
| 41 | + */ |
| 42 | +export class OAuth { |
| 43 | + constructor( |
| 44 | + private readonly transport: Transport, |
| 45 | + private readonly baseUrl: string, |
| 46 | + ) {} |
| 47 | + |
| 48 | + /** The consent-page URL your app opens in a browser. S256 is mandatory. */ |
| 49 | + authorizationUrl(params: AuthorizationUrlParams): string { |
| 50 | + const url = new URL(this.baseUrl + "/auth/device/login"); |
| 51 | + url.searchParams.set("app_id", params.appId); |
| 52 | + url.searchParams.set("redirect_uri", params.redirectUri); |
| 53 | + url.searchParams.set("state", params.state); |
| 54 | + url.searchParams.set("code_challenge", params.codeChallenge); |
| 55 | + url.searchParams.set("code_challenge_method", "S256"); |
| 56 | + return url.toString(); |
| 57 | + } |
| 58 | + |
| 59 | + /** |
| 60 | + * Exchange the one-time code from the callback for tokens. The code and |
| 61 | + * verifier are the credentials; no auth header is involved. |
| 62 | + */ |
| 63 | + async exchangeCode( |
| 64 | + params: { code: string; codeVerifier: string }, |
| 65 | + opts?: RequestOptions, |
| 66 | + ): Promise<DeviceTokens> { |
| 67 | + return this.transport.request( |
| 68 | + { |
| 69 | + method: "POST", |
| 70 | + path: "/auth/device/token", |
| 71 | + body: { code: params.code, code_verifier: params.codeVerifier }, |
| 72 | + }, |
| 73 | + opts, |
| 74 | + ); |
| 75 | + } |
| 76 | + |
| 77 | + /** |
| 78 | + * Trade a refresh token for a fresh pair. Refresh tokens rotate: the one |
| 79 | + * you sent is invalid afterwards, and grant scope changes propagate here. |
| 80 | + * Prefer `tokenProvider`, which handles rotation and persistence for you. |
| 81 | + */ |
| 82 | + async refreshTokens(refreshToken: string, opts?: RequestOptions): Promise<RefreshedTokens> { |
| 83 | + try { |
| 84 | + return await this.transport.request( |
| 85 | + { |
| 86 | + method: "POST", |
| 87 | + path: "/auth/device/refresh", |
| 88 | + body: { refresh_token: refreshToken }, |
| 89 | + }, |
| 90 | + opts, |
| 91 | + ); |
| 92 | + } catch (err) { |
| 93 | + if (err instanceof APIError && (err.status === 401 || err.status === 400)) { |
| 94 | + throw new SessionExpiredError({ cause: err }); |
| 95 | + } |
| 96 | + throw err; |
| 97 | + } |
| 98 | + } |
| 99 | + |
| 100 | + /** |
| 101 | + * A self-refreshing credential for `new Spoo({ token })`. Refreshes |
| 102 | + * proactively before the access token's `exp`, single-flight (concurrent |
| 103 | + * calls share one refresh, so a rotated pair is never persisted twice), |
| 104 | + * and reports every rotation through `onRefresh` for storage. |
| 105 | + * |
| 106 | + * Throws `SessionExpiredError` from the pending call when the refresh |
| 107 | + * token is rejected; catch it to send the user back through login. |
| 108 | + */ |
| 109 | + tokenProvider(options: TokenProviderOptions): () => Promise<string> { |
| 110 | + const skewMs = (options.expirySkew ?? 30) * 1000; |
| 111 | + let access = options.tokens.access_token; |
| 112 | + let refresh = options.tokens.refresh_token; |
| 113 | + let expiresAt = readExpiry(access); |
| 114 | + let inflight: Promise<void> | undefined; |
| 115 | + |
| 116 | + const doRefresh = async (): Promise<void> => { |
| 117 | + const next = await this.refreshTokens(refresh); |
| 118 | + access = next.access_token; |
| 119 | + refresh = next.refresh_token; |
| 120 | + expiresAt = readExpiry(access); |
| 121 | + await options.onRefresh?.(next); |
| 122 | + }; |
| 123 | + |
| 124 | + return async () => { |
| 125 | + if (expiresAt === undefined || Date.now() < expiresAt - skewMs) { |
| 126 | + return access; |
| 127 | + } |
| 128 | + inflight ??= doRefresh().finally(() => { |
| 129 | + inflight = undefined; |
| 130 | + }); |
| 131 | + await inflight; |
| 132 | + return access; |
| 133 | + }; |
| 134 | + } |
| 135 | +} |
| 136 | + |
| 137 | +function readExpiry(accessToken: string): number | undefined { |
| 138 | + const exp = decodeJwtPayload(accessToken)?.["exp"]; |
| 139 | + return typeof exp === "number" ? exp * 1000 : undefined; |
| 140 | +} |
0 commit comments