Skip to content

Commit 02d03ef

Browse files
committed
feat(oauth): Sign in with Spoo client half
PKCE S256 pair generation on WebCrypto, authorization URL builder, code exchange, refresh with rotation, and a single-flight self-refreshing token provider for the token option. Rejected refreshes throw the new SessionExpiredError so apps can prompt re-login instead of string matching. Verified against the RFC 7636 appendix B vector.
1 parent a2ce7ca commit 02d03ef

11 files changed

Lines changed: 486 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# spoo.me
22

3+
## 0.2.0
4+
5+
### Minor Changes
6+
7+
- Sign in with Spoo for connected apps: spoo.oauth with PKCE pair generation,
8+
authorization URL building, code exchange, token refresh with rotation, and a
9+
self-refreshing single-flight token provider for `new Spoo({ token })`.
10+
Rejected refreshes throw the new SessionExpiredError.
11+
312
## 0.1.0
413

514
### Minor Changes

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,29 @@ const stats = await spoo.public.stats("alias");
110110
const preview = await spoo.public.preview("alias");
111111
```
112112

113+
## Sign in with Spoo (connected apps)
114+
115+
Building an app that acts on behalf of a spoo.me user? The SDK ships the
116+
client half of the PKCE flow: pair generation, the consent URL, code
117+
exchange, and a self-refreshing token provider that handles rotation.
118+
119+
```ts
120+
const pkce = await generatePkcePair();
121+
const url = spoo.oauth.authorizationUrl({ appId, redirectUri, state, codeChallenge: pkce.challenge });
122+
// open `url`, receive ?code=... on your redirect URI
123+
const tokens = await spoo.oauth.exchangeCode({ code, codeVerifier: pkce.verifier });
124+
125+
const client = new Spoo({
126+
token: spoo.oauth.tokenProvider({ tokens, onRefresh: persist }),
127+
});
128+
```
129+
130+
Your app drives the browser and stores tokens; the SDK never does either.
131+
Refresh tokens rotate on every refresh, so persist what `onRefresh` hands
132+
you. A rejected refresh throws `SessionExpiredError`: send the user back
133+
through login. App ids and redirect URIs are registered with spoo.me and
134+
matched exactly. See [`examples/sign-in-with-spoo.ts`](./examples/sign-in-with-spoo.ts).
135+
113136
## Errors
114137

115138
Failed requests throw a typed subclass of `SpooError`:

examples/sign-in-with-spoo.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Sign in with Spoo from a connected app (authorization-code + PKCE).
2+
// The SDK handles the protocol; your app drives the browser and stores tokens.
3+
// Your app_id and its exact redirect URI must be registered with spoo.me.
4+
import { Spoo, generatePkcePair, generateState } from "spoo.me";
5+
6+
const spoo = new Spoo(); // anonymous: the flow endpoints need no credentials
7+
8+
// 1. Send the user to consent
9+
const pkce = await generatePkcePair();
10+
const state = generateState();
11+
const url = spoo.oauth.authorizationUrl({
12+
appId: "your-app",
13+
redirectUri: "http://127.0.0.1:53682/callback",
14+
state,
15+
codeChallenge: pkce.challenge,
16+
});
17+
console.log("Open:", url);
18+
19+
// 2. Your callback receives ?code=...&state=... — verify state, then exchange.
20+
declare const codeFromCallback: string;
21+
const tokens = await spoo.oauth.exchangeCode({
22+
code: codeFromCallback,
23+
codeVerifier: pkce.verifier,
24+
});
25+
console.log("Signed in as", tokens.user.email);
26+
27+
// 3. Wrap the pair in a self-refreshing provider and use the API.
28+
// Refresh tokens rotate: persist every pair onRefresh hands you, the old
29+
// refresh token is dead the moment it fires.
30+
const provider = spoo.oauth.tokenProvider({
31+
tokens,
32+
onRefresh: (next) => saveToSecureStorage(next),
33+
});
34+
35+
const client = new Spoo({ token: provider });
36+
for await (const link of await client.links.list()) {
37+
console.log(link.alias, link.total_clicks);
38+
}
39+
40+
// When the refresh token is rejected (grant revoked, session expired), calls
41+
// throw SessionExpiredError: catch it and send the user back to step 1.
42+
43+
declare function saveToSecureStorage(t: { access_token: string; refresh_token: string }): void;

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "spoo.me",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "Official TypeScript SDK for the spoo.me link management API",
55
"keywords": [
66
"spoo.me",

src/client.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Stats } from "./resources/stats.js";
44
import { PublicLinks } from "./resources/public.js";
55
import { Emoji } from "./resources/emoji.js";
66
import { Misc } from "./resources/misc.js";
7+
import { OAuth } from "./resources/oauth.js";
78

89
export interface SpooOptions {
910
/**
@@ -41,13 +42,17 @@ export class Spoo {
4142
readonly public: PublicLinks;
4243
readonly emoji: Emoji;
4344
readonly misc: Misc;
45+
/** Sign in with Spoo, client half: PKCE, code exchange, refreshing tokens. */
46+
readonly oauth: OAuth;
4447

4548
/** @internal Transport shared by every resource namespace. */
4649
readonly _transport: Transport;
4750

4851
constructor(options: SpooOptions = {}) {
4952
const apiKey = options.apiKey ?? readEnv("SPOO_API_KEY");
5053

54+
const baseUrl = options.baseUrl ?? "https://spoo.me";
55+
5156
if (apiKey !== undefined && isBrowser() && options.dangerouslyAllowBrowser !== true) {
5257
throw new Error(
5358
"Refusing to use an API key in a browser: it would be visible to every visitor. " +
@@ -57,7 +62,7 @@ export class Spoo {
5762
}
5863

5964
this._transport = new Transport({
60-
baseUrl: options.baseUrl ?? "https://spoo.me",
65+
baseUrl,
6166
...(apiKey !== undefined ? { apiKey } : {}),
6267
...(options.token !== undefined ? { token: options.token } : {}),
6368
...(options.fetch !== undefined ? { fetch: options.fetch } : {}),
@@ -72,6 +77,7 @@ export class Spoo {
7277
this.public = new PublicLinks(this._transport);
7378
this.emoji = new Emoji(this._transport);
7479
this.misc = new Misc(this._transport);
80+
this.oauth = new OAuth(this._transport, baseUrl.replace(/\/+$/, ""));
7581
}
7682
}
7783

src/core/errors.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,20 @@ export class ServiceUnavailableError extends APIError {
168168
override name = "ServiceUnavailableError";
169169
}
170170

171+
/**
172+
* A connected-app session can no longer be refreshed: the refresh token was
173+
* rejected (rotated away, grant revoked, or expired). The only recovery is
174+
* sending the user through Sign in with Spoo again.
175+
*/
176+
export class SessionExpiredError extends SpooError {
177+
override name = "SessionExpiredError";
178+
179+
constructor(options?: { cause?: unknown }) {
180+
super("Session expired: the refresh token was rejected. Re-authenticate the user.");
181+
if (options?.cause !== undefined) this.cause = options.cause;
182+
}
183+
}
184+
171185
/** The request never produced a response (network failure, DNS, reset). */
172186
export class APIConnectionError extends SpooError {
173187
override name = "APIConnectionError";

src/core/pkce.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* PKCE (RFC 7636) primitives on WebCrypto, so they run everywhere the SDK
3+
* does. spoo.me's device flow mandates S256; plain is not supported.
4+
*/
5+
6+
export interface PkcePair {
7+
/** Random secret the app keeps until the code exchange. 43 chars, base64url. */
8+
verifier: string;
9+
/** S256 challenge derived from the verifier, sent in the authorization URL. */
10+
challenge: string;
11+
}
12+
13+
export async function generatePkcePair(): Promise<PkcePair> {
14+
const bytes = new Uint8Array(32);
15+
crypto.getRandomValues(bytes);
16+
const verifier = base64url(bytes);
17+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
18+
return { verifier, challenge: base64url(new Uint8Array(digest)) };
19+
}
20+
21+
/** Random state parameter for CSRF binding of the authorization redirect. */
22+
export function generateState(): string {
23+
const bytes = new Uint8Array(16);
24+
crypto.getRandomValues(bytes);
25+
return base64url(bytes);
26+
}
27+
28+
export function base64url(bytes: Uint8Array): string {
29+
let binary = "";
30+
for (const b of bytes) binary += String.fromCharCode(b);
31+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
32+
}
33+
34+
/** Decode a JWT payload without verifying it. Verification is the server's job;
35+
* the client only reads `exp` to schedule proactive refresh. */
36+
export function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
37+
const part = token.split(".")[1];
38+
if (part === undefined) return undefined;
39+
try {
40+
const padded = part.replace(/-/g, "+").replace(/_/g, "/");
41+
return JSON.parse(atob(padded)) as Record<string, unknown>;
42+
} catch {
43+
return undefined;
44+
}
45+
}

src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export {
1313
PayloadTooLargeError,
1414
ValidationError,
1515
RateLimitError,
16+
SessionExpiredError,
1617
ContentBlockedError,
1718
InternalServerError,
1819
ServiceUnavailableError,
@@ -52,4 +53,12 @@ export {
5253
} from "./resources/public.js";
5354
export { Emoji, type EmojiSet, type EmojiEntry } from "./resources/emoji.js";
5455
export { Misc, type HealthStatus } from "./resources/misc.js";
56+
export {
57+
OAuth,
58+
type DeviceTokens,
59+
type RefreshedTokens,
60+
type AuthorizationUrlParams,
61+
type TokenProviderOptions,
62+
} from "./resources/oauth.js";
63+
export { generatePkcePair, generateState, type PkcePair } from "./core/pkce.js";
5564
export type { components as ApiSchema } from "./generated/schema.js";

src/resources/oauth.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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+
}

src/version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
// Generated by scripts/write-version.mjs — do not edit.
2-
export const SDK_VERSION = "0.1.0";
2+
export const SDK_VERSION = "0.2.0";

0 commit comments

Comments
 (0)