|
1 | 1 | /** |
2 | | - * `withAuth(fetcher)` — wraps an ofetch-like call with the current session. |
| 2 | + * `withAuth(baseUrl)` wraps an ofetch-like call with the current session. |
3 | 3 | * Adds `Authorization: Bearer …`, refreshes on 401, re-reads the marker file |
4 | 4 | * before refreshing so concurrent CLI invocations can share a rotated token. |
5 | 5 | * |
6 | 6 | * Refresh is never preemptive. SKILLD_TOKEN env scheme is treated as hard |
7 | 7 | * expiry: a 401 propagates instead of triggering refresh. |
8 | 8 | */ |
9 | 9 |
|
| 10 | +import type { StorageScheme, StoredSession } from './store.ts' |
10 | 11 | import type { TokenResponse } from './types.ts' |
11 | 12 | import { ofetch } from 'ofetch' |
12 | | -import { getRegistryBase } from '../registry/client.ts' |
13 | 13 | import { loadSession, saveSession } from './store.ts' |
14 | 14 |
|
15 | 15 | export interface AuthedFetcher { |
16 | 16 | <T>(url: string, init?: Parameters<typeof ofetch<T>>[1]): Promise<T> |
17 | 17 | } |
18 | 18 |
|
19 | | -async function refreshSession(refreshToken: string): Promise<TokenResponse | null> { |
20 | | - const base = getRegistryBase() |
21 | | - return ofetch<TokenResponse>(`${base}/cli/oauth/refresh`, { |
22 | | - method: 'POST', |
23 | | - body: { refresh_token: refreshToken }, |
24 | | - }).catch(() => null) |
| 19 | +interface AuthenticatedFetchDependencies { |
| 20 | + baseUrl: string |
| 21 | + fetch: AuthedFetcher |
| 22 | + loadSession: () => Promise<StoredSession | null> |
| 23 | + saveSession: (session: Parameters<typeof saveSession>[0]) => Promise<StorageScheme> |
25 | 24 | } |
26 | 25 |
|
27 | | -export function withAuth(): AuthedFetcher { |
| 26 | +type FetchAttempt<T> |
| 27 | + = | { _tag: 'Ok', value: T } |
| 28 | + | { _tag: 'Err', error: unknown } |
| 29 | + |
| 30 | +function isAuthFailure(error: unknown): boolean { |
| 31 | + if (typeof error !== 'object' || error === null || !('statusCode' in error)) |
| 32 | + return false |
| 33 | + const statusCode = (error as { statusCode?: unknown }).statusCode |
| 34 | + return statusCode === 401 || statusCode === 403 |
| 35 | +} |
| 36 | + |
| 37 | +export function createAuthenticatedFetch(deps: AuthenticatedFetchDependencies): AuthedFetcher { |
28 | 38 | return async <T>(url: string, init?: Parameters<typeof ofetch<T>>[1]): Promise<T> => { |
29 | | - const session = await loadSession() |
| 39 | + const session = await deps.loadSession() |
30 | 40 | if (!session) |
31 | 41 | throw new Error('auth required') |
32 | 42 |
|
33 | | - const send = (token: string): Promise<T> => ofetch<T>(url, { |
| 43 | + const send = (token: string): Promise<T> => deps.fetch<T>(url, { |
34 | 44 | ...init, |
35 | 45 | headers: { ...(init?.headers as any), Authorization: `Bearer ${token}` }, |
36 | 46 | }) |
37 | 47 |
|
38 | | - const fail401Codes = new Set([401, 403]) |
39 | | - |
40 | | - const firstAttempt = await send(session.accessToken).catch((err: { statusCode?: number } & Error) => err) |
41 | | - if (!(firstAttempt instanceof Error) || !fail401Codes.has((firstAttempt as { statusCode?: number }).statusCode ?? 0)) |
42 | | - return firstAttempt as T |
| 48 | + const firstAttempt: FetchAttempt<T> = await send(session.accessToken) |
| 49 | + .then(value => ({ _tag: 'Ok' as const, value })) |
| 50 | + .catch(error => ({ _tag: 'Err' as const, error })) |
| 51 | + if (firstAttempt._tag === 'Ok') |
| 52 | + return firstAttempt.value |
| 53 | + if (!isAuthFailure(firstAttempt.error)) |
| 54 | + throw firstAttempt.error |
43 | 55 |
|
44 | 56 | if (session.scheme === 'env' || !session.refreshToken) |
45 | | - throw firstAttempt |
| 57 | + throw firstAttempt.error |
46 | 58 |
|
47 | 59 | // Re-read marker; another process may have already rotated. |
48 | | - const fresh = await loadSession() |
| 60 | + const fresh = await deps.loadSession() |
49 | 61 | const candidateRefresh = fresh?.refreshToken ?? session.refreshToken |
50 | | - if (fresh && fresh.accessToken !== session.accessToken) { |
| 62 | + if (fresh && fresh.accessToken !== session.accessToken) |
51 | 63 | return send(fresh.accessToken) |
52 | | - } |
53 | 64 |
|
54 | | - const rotated = await refreshSession(candidateRefresh) |
55 | | - if (!rotated) |
56 | | - throw firstAttempt |
| 65 | + const rotated = await deps.fetch<TokenResponse>(`${deps.baseUrl}/cli/oauth/refresh`, { |
| 66 | + method: 'POST', |
| 67 | + body: { refresh_token: candidateRefresh }, |
| 68 | + }) |
57 | 69 |
|
58 | | - await saveSession({ |
| 70 | + await deps.saveSession({ |
59 | 71 | login: rotated.login, |
60 | 72 | accessToken: rotated.accessToken, |
61 | 73 | refreshToken: rotated.refreshToken, |
62 | 74 | expiresAt: rotated.expiresAt, |
| 75 | + host: session.host, |
63 | 76 | tokens: { accessToken: rotated.accessToken, refreshToken: rotated.refreshToken }, |
64 | 77 | }) |
65 | 78 |
|
66 | 79 | return send(rotated.accessToken) |
67 | 80 | } |
68 | 81 | } |
| 82 | + |
| 83 | +export function withAuth(baseUrl: string): AuthedFetcher { |
| 84 | + return createAuthenticatedFetch({ |
| 85 | + baseUrl, |
| 86 | + fetch: ofetch, |
| 87 | + loadSession, |
| 88 | + saveSession, |
| 89 | + }) |
| 90 | +} |
0 commit comments