Skip to content

Commit aa06439

Browse files
authored
fix: immediate token exchange and simplified storage API (#34)
1 parent ee6964c commit aa06439

26 files changed

+858
-571
lines changed

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
"editor.defaultFormatter": "esbenp.prettier-vscode",
44
"cSpell.words": [
55
"bunx",
6+
"EADDRINUSE",
67
"konstantin",
78
"kriasoft",
89
"modelcontextprotocol",
10+
"myapp",
11+
"PKCE",
912
"publint",
1013
"srcpack",
1114
"streamable",

CLAUDE.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# OAuth Callback Project Guide
22

3+
## Documentation
4+
5+
**ADRs** (`docs/adr/NNN-slug.md`): Architectural decisions (reference as ADR-NNN)
6+
**SPECs** (`docs/specs/slug.md`): Design specifications (reference as SPEC-slug)
7+
38
## Project Structure
49

510
```bash
@@ -56,7 +61,7 @@ oauth-callback/
5661
- `browserAuth()` - MCP SDK-compatible OAuth provider
5762
- `inMemoryStore()` - Ephemeral token storage
5863
- `fileStore()` - Persistent file-based token storage
59-
- Type exports: `BrowserAuthOptions`, `Tokens`, `TokenStore`, `ClientInfo`, `OAuthSession`, `OAuthStore`
64+
- Type exports: `BrowserAuthOptions`, `Tokens`, `TokenStore`, `ClientInfo`, `OAuthStore`
6065

6166
## Key Constraints
6267

README.md

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ import { browserAuth, inMemoryStore } from "oauth-callback/mcp";
127127
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
128128
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
129129

130+
const serverUrl = new URL("https://mcp.notion.com/mcp");
131+
130132
// Create MCP-compatible OAuth provider
131133
const authProvider = browserAuth({
132134
port: 3000,
@@ -135,18 +137,29 @@ const authProvider = browserAuth({
135137
store: inMemoryStore(), // Or fileStore() for persistence
136138
});
137139

138-
// Use with MCP SDK transport
139-
const transport = new StreamableHTTPClientTransport(
140-
new URL("https://mcp.notion.com/mcp"),
141-
{ authProvider },
142-
);
143-
144140
const client = new Client(
145141
{ name: "my-app", version: "1.0.0" },
146142
{ capabilities: {} },
147143
);
148144

149-
await client.connect(transport);
145+
// Connect with OAuth retry: first attempt completes OAuth and saves tokens,
146+
// but SDK returns before checking them. Second attempt succeeds.
147+
async function connectWithOAuthRetry() {
148+
const transport = new StreamableHTTPClientTransport(serverUrl, {
149+
authProvider,
150+
});
151+
try {
152+
await client.connect(transport);
153+
} catch (error: any) {
154+
if (error.message === "Unauthorized") {
155+
await client.connect(
156+
new StreamableHTTPClientTransport(serverUrl, { authProvider }),
157+
);
158+
} else throw error;
159+
}
160+
}
161+
162+
await connectWithOAuthRetry();
150163
```
151164

152165
#### Token Storage Options
@@ -275,7 +288,7 @@ class OAuthError extends Error {
275288

276289
### `browserAuth(options)`
277290

278-
Available from `oauth-callback/mcp`. Creates an MCP SDK-compatible OAuth provider for browser-based flows. Handles Dynamic Client Registration (DCR), token storage, and automatic refresh.
291+
Available from `oauth-callback/mcp`. Creates an MCP SDK-compatible OAuth provider for browser-based flows. Handles Dynamic Client Registration (DCR) and token storage. Expired tokens trigger re-authentication.
279292

280293
#### Parameters
281294

docs/.vitepress/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export default withMermaid(
5858
{ text: "API", link: "/api/get-auth-code" },
5959
{ text: "Examples", link: "/examples/notion" },
6060
{
61-
text: "v2.0.0",
61+
text: "v2.2.0",
6262
items: [
6363
{
6464
text: "Release Notes",
@@ -82,6 +82,7 @@ export default withMermaid(
8282
},
8383
{ text: "Getting Started", link: "/getting-started" },
8484
{ text: "Core Concepts", link: "/core-concepts" },
85+
{ text: "ADRs", link: "/adr/" },
8586
],
8687
},
8788
{

docs/adr/000-template.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# ADR-NNN Title
2+
3+
**Status:** Proposed | Accepted | Deprecated | Superseded
4+
**Date:** YYYY-MM-DD
5+
**Tags:** tag1, tag2
6+
7+
## Problem
8+
9+
- One or two sentences on the decision trigger or constraint.
10+
11+
## Decision
12+
13+
- The chosen approach in a short paragraph.
14+
15+
## Alternatives (brief)
16+
17+
- Option A — why not.
18+
- Option B — why not.
19+
20+
## Impact
21+
22+
- Positive:
23+
- Negative/Risks:
24+
25+
## Links
26+
27+
- Code/Docs:
28+
- Related ADRs:

docs/adr/001-no-refresh-tokens.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# ADR-001: No Refresh Tokens in browserAuth
2+
3+
**Status:** Accepted
4+
**Date:** 2025-01-25
5+
**Tags:** oauth, mcp, security
6+
7+
## Problem
8+
9+
- Should `browserAuth` handle OAuth refresh tokens and automatic token renewal?
10+
11+
## Decision
12+
13+
- `browserAuth` intentionally does not request or handle refresh tokens.
14+
- When tokens expire, `tokens()` returns `undefined`, signaling the MCP SDK to re-authenticate.
15+
- The SDK's built-in retry logic handles re-auth transparently.
16+
17+
Rationale:
18+
19+
- **MCP SDK lifecycle**: The SDK expects auth providers to return `undefined` for expired tokens, triggering its standard re-auth flow.
20+
- **CLI/desktop UX**: Interactive re-consent is acceptable and often preferred over silent background refresh.
21+
- **Simplicity**: Avoiding refresh eliminates token rotation complexity, race conditions, and concurrent refresh handling.
22+
- **Security**: No long-lived refresh tokens stored; each session requires explicit user consent.
23+
24+
## Alternatives (brief)
25+
26+
- **Implement refresh flow** — Adds complexity (token rotation, concurrency), conflicts with SDK's re-auth expectations, stores long-lived credentials.
27+
- **Optional refresh via config** — Increases API surface, creates two divergent code paths to maintain.
28+
29+
## Impact
30+
31+
- Positive: Simpler implementation, predictable behavior, aligns with MCP SDK design.
32+
- Negative/Risks: More frequent browser prompts for long-running sessions (acceptable for CLI tools).
33+
34+
## Links
35+
36+
- Code: `src/auth/browser-auth.ts`
37+
- Related: MCP SDK auth interface in `@modelcontextprotocol/sdk/client/auth`
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# ADR-002: Immediate Token Exchange in redirectToAuthorization()
2+
3+
**Status:** Accepted
4+
**Date:** 2025-01-25
5+
**Tags:** oauth, mcp, sdk-integration
6+
7+
## Problem
8+
9+
The MCP SDK's `auth()` flow works as follows:
10+
11+
1. Check `provider.tokens()` — if valid tokens exist, return `'AUTHORIZED'`
12+
2. If no tokens, start authorization: call `redirectToAuthorization(url)`
13+
3. **Immediately** return `'REDIRECT'` (without re-checking tokens)
14+
15+
For web-based OAuth, this makes sense: `redirectToAuthorization()` triggers a page redirect and control never returns synchronously. The SDK expects authentication to complete in a subsequent request.
16+
17+
For CLI/desktop apps using `browserAuth()`, control **does** return synchronously—we capture the callback in-process via a local HTTP server. We exchange tokens inside `redirectToAuthorization()`, but the SDK has already decided to return `'REDIRECT'`, causing `UnauthorizedError`.
18+
19+
## Decision
20+
21+
Exchange tokens **inside** `redirectToAuthorization()` and document the retry pattern as the expected usage:
22+
23+
```typescript
24+
// First connect triggers OAuth flow and saves tokens, but SDK returns
25+
// 'REDIRECT' before checking. Second connect finds valid tokens.
26+
async function connectWithOAuthRetry(client, serverUrl, authProvider) {
27+
const transport = new StreamableHTTPClientTransport(serverUrl, {
28+
authProvider,
29+
});
30+
try {
31+
await client.connect(transport);
32+
} catch (error) {
33+
if (error.message === "Unauthorized") {
34+
await client.connect(
35+
new StreamableHTTPClientTransport(serverUrl, { authProvider }),
36+
);
37+
} else throw error;
38+
}
39+
}
40+
```
41+
42+
**Why a new transport on retry?** The transport caches connection state internally. A fresh transport ensures clean reconnection.
43+
44+
## Rationale
45+
46+
- **SDK constraint**: No hook exists between redirect completion and the `'REDIRECT'` return. The SDK interface (`Promise<void>`) cannot signal "auth completed."
47+
- **In-process capture**: CLI apps don't have page redirects that would trigger a fresh auth check cycle.
48+
- **Correctness over elegance**: The retry is unusual but reliable—tokens are always saved before the error.
49+
50+
## Alternatives Considered
51+
52+
| Alternative | Why Rejected |
53+
| ---------------------------------------------- | ------------------------------------------------------------- |
54+
| `transport.finishAuth(callbackUrl)` | Breaks provider encapsulation; doesn't fit in-process capture |
55+
| Return tokens from `redirectToAuthorization()` | SDK interface expects `Promise<void>` |
56+
| Upstream SDK change | Not viable for library consumers |
57+
58+
## Impact
59+
60+
- **Positive**: Self-contained auth flow; no external coordination needed
61+
- **Negative**: First connection always throws `UnauthorizedError` after OAuth—must be documented clearly
62+
63+
## Links
64+
65+
- Code: `src/auth/browser-auth.ts` lines 254-368
66+
- MCP SDK auth interface: `@modelcontextprotocol/sdk/client/auth.js`
67+
- Related: ADR-001 (no refresh tokens)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# ADR-003: Stable Client Metadata Across DCR
2+
3+
**Status:** Accepted
4+
**Date:** 2025-01-25
5+
**Tags:** oauth, dcr, security
6+
7+
## Problem
8+
9+
- During Dynamic Client Registration (DCR), the authorization server may return different capabilities than requested (e.g., `token_endpoint_auth_method`).
10+
- If `clientMetadata` adapts to DCR responses, subsequent token requests may fail when the AS caches the original registration metadata.
11+
12+
## Decision
13+
14+
- `clientMetadata` is immutable after construction.
15+
- `token_endpoint_auth_method` is determined at construction: `client_secret_post` if `clientSecret` is provided, `none` otherwise. DCR responses never change this value.
16+
- DCR credentials (`client_id`, `client_secret`) are stored separately and never mutate the auth method.
17+
18+
## Alternatives (brief)
19+
20+
- **Dynamic metadata evolution** — Adapting to DCR responses seems flexible but causes cache mismatches with AS that remember original registration.
21+
- **Per-request method detection** — Adds complexity and non-deterministic behavior across retries.
22+
23+
## Impact
24+
25+
- Positive: Predictable behavior with all AS implementations; eliminates cache-related auth failures.
26+
- Negative/Risks: None identified; the fixed method (`client_secret_post`) has universal support.
27+
28+
## Links
29+
30+
- Code: `src/auth/browser-auth.ts`
31+
- Related ADRs: ADR-001 (No Refresh Tokens), ADR-002 (Immediate Token Exchange)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# ADR-004: Conditional OAuth State Validation
2+
3+
**Status:** Accepted
4+
**Date:** 2025-01-25
5+
**Tags:** oauth, security, csrf
6+
7+
## Problem
8+
9+
- RFC 6749 recommends `state` for CSRF protection, but RFC 8252 (native apps) relies on loopback redirect for security.
10+
- Some authorization servers don't echo `state` back; others require it.
11+
- Strict validation breaks compatibility; no validation weakens security.
12+
13+
## Decision
14+
15+
- Validate `state` only if it was present in the authorization URL.
16+
- If the auth URL includes `state` and the callback doesn't match, reject as CSRF.
17+
- If the auth URL omits `state`, accept callbacks without state validation.
18+
19+
Rationale:
20+
21+
- **Defense-in-depth**: Loopback binding (127.0.0.1) prevents network CSRF, but state adds protection against local attacks (malicious apps, browser extensions intercepting localhost).
22+
- **CLI threat model**: Unlike web apps, CLI tools face local machine threats—other processes can probe localhost ports. State validation detects if a callback arrives from an unrelated auth flow.
23+
- **Compatibility**: Authorization servers have inconsistent state handling. Conditional validation works with all servers while providing protection when available.
24+
25+
## Alternatives (brief)
26+
27+
- **Always require state** — Breaks servers that don't echo state or don't support it.
28+
- **Never validate state** — Loopback provides baseline security, but ignores state when the AS cooperates.
29+
- **Generate state internally always** — Conflicts with auth URLs that already include state from the MCP SDK.
30+
31+
## Impact
32+
33+
- Positive: Maximum security when AS supports state; universal compatibility otherwise.
34+
- Negative/Risks: If an AS echoes arbitrary state values without validation, the protection is weaker (rare edge case).
35+
36+
## Links
37+
38+
- Code: `src/auth/browser-auth.ts:297-300`
39+
- RFC 6749 Section 10.12 (CSRF Protection)
40+
- RFC 8252 Section 8.1 (Loopback Redirect)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# ADR-005: OAuthStore Responsibility Reduction
2+
3+
**Status:** Accepted
4+
**Date:** 2025-01-25
5+
**Tags:** api, storage, simplification
6+
7+
## Problem
8+
9+
The store was accumulating OAuth flow state (session, nonce, state parameter) alongside persistent data (tokens, client registration). This blurred the line between "what survives a crash" and "what's ephemeral by design," making the API harder to reason about and test.
10+
11+
## Decision
12+
13+
The store is responsible **only** for data that must survive process restarts:
14+
15+
| Stored | Not Stored |
16+
| --------------------- | ----------------- |
17+
| `tokens` | `state` parameter |
18+
| `client` (DCR result) | `nonce` |
19+
| `codeVerifier` (PKCE) | session objects |
20+
21+
The `codeVerifier` is the sole flow artifact persisted—it enables completing an in-progress authorization if the process crashes after browser launch but before callback.
22+
23+
## Alternatives (brief)
24+
25+
- **Full session persistence** — Would enable crash-recovery at any point, but adds complexity for a rare edge case. Users can simply restart the flow.
26+
- **No verifier persistence** — Simpler, but loses the most common crash scenario (user switches apps, process dies).
27+
28+
## Impact
29+
30+
- Positive: Cleaner mental model; store implementations are trivial to write and test.
31+
- Negative: If the process crashes before `codeVerifier` is saved, the flow must restart. This is acceptable—it's a sub-second window.
32+
33+
## Links
34+
35+
- Code: `src/storage/`, `src/mcp-types.ts`
36+
- Related: ADR-002 (Immediate Token Exchange)

0 commit comments

Comments
 (0)