-
Notifications
You must be signed in to change notification settings - Fork 32
Expand file tree
/
Copy pathoauthFlow.ts
More file actions
164 lines (145 loc) · 5.2 KB
/
Copy pathoauthFlow.ts
File metadata and controls
164 lines (145 loc) · 5.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
// src/auth/oauthFlow.ts
const TIMEOUT_MS = 5000;
// Deliberately not the SDK's OAuthTokens, which requires token_type and makes
// expires_in optional — the inverse of what post() enforces. expires_in must
// be present because token stores schedule refresh from it; token_type may be
// absent in MediaWiki responses.
export interface TokenResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
scope?: string;
token_type?: string;
}
export type FlowErrorKind = 'invalid_grant' | 'invalid_client' | 'transient' | 'malformed';
export class OAuthFlowError extends Error {
constructor(
public readonly kind: FlowErrorKind,
message: string,
) {
super(message);
this.name = 'OAuthFlowError';
}
}
export type RefreshErrorClass = 'retryable' | 'dead';
// Classifies a refresh-grant failure so every caller reacts consistently. A
// transient or malformed upstream failure (wiki 5xx, connection blip, garbled
// body) is `retryable`: the presented refresh token is still good, so a caller
// should surface a retryable error rather than force a full re-authentication. A
// genuine rejection (invalid_grant/invalid_client) — or any non-OAuth error — is
// `dead`: the refresh token cannot be used again.
export function classifyRefreshError(err: unknown): RefreshErrorClass {
if (err instanceof OAuthFlowError && (err.kind === 'transient' || err.kind === 'malformed')) {
return 'retryable';
}
return 'dead';
}
export interface ExchangeArgs {
tokenEndpoint: string;
code: string;
verifier: string;
clientId: string;
redirectUri: string;
// Set only when the upstream consumer is confidential. MediaWiki requires a
// client secret on the refresh grant (a public client can't refresh); sending
// it here lets the same consumer be used confidentially for both grants.
clientSecret?: string;
}
export interface RefreshArgs {
tokenEndpoint: string;
refreshToken: string;
clientId: string;
// See ExchangeArgs.clientSecret. Without it, MediaWiki rejects a public
// client's refresh with invalid_client.
clientSecret?: string;
}
// Adds `client_secret` to a token-request body only when the upstream consumer
// is confidential, keeping the public/PKCE default byte-for-byte unchanged.
function withClientSecret(
body: Record<string, string>,
clientSecret?: string,
): Record<string, string> {
return clientSecret ? { ...body, client_secret: clientSecret } : body;
}
export async function exchangeCode(a: ExchangeArgs): Promise<TokenResponse> {
return post(
a.tokenEndpoint,
withClientSecret(
{
grant_type: 'authorization_code',
code: a.code,
code_verifier: a.verifier,
client_id: a.clientId,
redirect_uri: a.redirectUri,
},
a.clientSecret,
),
);
}
export async function refreshTokens(a: RefreshArgs): Promise<TokenResponse> {
return post(
a.tokenEndpoint,
withClientSecret(
{
grant_type: 'refresh_token',
refresh_token: a.refreshToken,
client_id: a.clientId,
},
a.clientSecret,
),
);
}
async function post(endpoint: string, body: Record<string, string>): Promise<TokenResponse> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
let res: Response;
try {
res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(body).toString(),
signal: ctrl.signal,
});
} catch (err: unknown) {
throw new OAuthFlowError('transient', `Token endpoint request failed: ${String(err)}`);
} finally {
clearTimeout(timer);
}
// 5xx → transient
if (res.status >= 500) {
throw new OAuthFlowError('transient', `Token endpoint returned ${res.status}`);
}
let json: unknown;
try {
json = await res.json();
} catch {
throw new OAuthFlowError('malformed', 'Token endpoint response is not valid JSON');
}
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- post-JSON boundary; fields validated immediately below
const obj = json as Record<string, unknown>;
// Any non-2xx below 500: classify by the OAuth error code. MediaWiki returns
// 401 invalid_client (not 400) when a public client cannot authenticate for
// the refresh grant, so invalid_client must be recognised regardless of status
// — otherwise a permanent misconfiguration reads as a retryable transient error.
if (!res.ok) {
const code = typeof obj.error === 'string' ? obj.error : '';
if (code === 'invalid_grant') {
throw new OAuthFlowError('invalid_grant', 'Token request failed: invalid_grant');
}
if (code === 'invalid_client') {
throw new OAuthFlowError('invalid_client', 'Token request failed: invalid_client');
}
throw new OAuthFlowError('transient', `Token request failed: ${code || res.status}`);
}
// 200 but missing required fields
if (typeof obj.access_token !== 'string' || typeof obj.expires_in !== 'number') {
throw new OAuthFlowError('malformed', 'Token response missing access_token or expires_in');
}
return {
access_token: obj.access_token,
refresh_token: typeof obj.refresh_token === 'string' ? obj.refresh_token : undefined,
expires_in: obj.expires_in,
scope: typeof obj.scope === 'string' ? obj.scope : undefined,
token_type: typeof obj.token_type === 'string' ? obj.token_type : undefined,
};
}