@alteran/astro 0.7.6 → 0.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/lib/oauth/observability.ts +53 -12
- package/src/pages/oauth/token.ts +78 -33
package/package.json
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { errorMessage } from '../errors';
|
|
2
2
|
|
|
3
|
+
export type OauthEndpoint = 'par' | 'authorize' | 'consent' | 'token' | 'revoke';
|
|
4
|
+
|
|
3
5
|
export type OauthParStage =
|
|
4
6
|
| 'metadata_fetch'
|
|
5
7
|
| 'metadata_shape'
|
|
@@ -9,6 +11,17 @@ export type OauthParStage =
|
|
|
9
11
|
| 'outer'
|
|
10
12
|
| 'success';
|
|
11
13
|
|
|
14
|
+
export type OauthTokenStage =
|
|
15
|
+
| 'dpop'
|
|
16
|
+
| 'parse'
|
|
17
|
+
| 'unsupported_grant'
|
|
18
|
+
| 'auth_code'
|
|
19
|
+
| 'refresh_token'
|
|
20
|
+
| 'client_auth'
|
|
21
|
+
| 'session_issue'
|
|
22
|
+
| 'outer'
|
|
23
|
+
| 'success';
|
|
24
|
+
|
|
12
25
|
export type OauthParFormSummary = {
|
|
13
26
|
redirectUri: string;
|
|
14
27
|
responseType: string;
|
|
@@ -20,20 +33,30 @@ export type OauthParFormSummary = {
|
|
|
20
33
|
hasClientAssertion: boolean;
|
|
21
34
|
};
|
|
22
35
|
|
|
23
|
-
export type
|
|
36
|
+
export type OauthTokenFormSummary = {
|
|
37
|
+
grantType: string;
|
|
38
|
+
clientId: string;
|
|
39
|
+
redirectUri: string;
|
|
40
|
+
hasCode: boolean;
|
|
41
|
+
hasCodeVerifier: boolean;
|
|
42
|
+
hasRefreshToken: boolean;
|
|
43
|
+
hasClientAssertion: boolean;
|
|
44
|
+
clientAssertionType: string | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type OauthLogDetails = {
|
|
48
|
+
endpoint: OauthEndpoint;
|
|
49
|
+
stage: string;
|
|
24
50
|
outcome: 'ok' | 'error';
|
|
25
51
|
requestId?: string | null;
|
|
26
52
|
error?: unknown;
|
|
27
53
|
clientId?: string | null;
|
|
28
|
-
form?:
|
|
54
|
+
form?: Record<string, unknown> | null;
|
|
29
55
|
metadataStatus?: number | null;
|
|
30
56
|
metadataContentType?: string | null;
|
|
31
57
|
metadataRedirected?: boolean | null;
|
|
32
58
|
};
|
|
33
59
|
|
|
34
|
-
// Read context off an Error attached by safeFetchJson when the metadata HTTP
|
|
35
|
-
// roundtrip itself failed; absent for shape/validation errors that never made
|
|
36
|
-
// a request.
|
|
37
60
|
export type FetchAugmentedError = Error & {
|
|
38
61
|
metadataStatus?: number;
|
|
39
62
|
metadataContentType?: string | null;
|
|
@@ -69,16 +92,25 @@ export function summarizeParForm(form: URLSearchParams): OauthParFormSummary {
|
|
|
69
92
|
};
|
|
70
93
|
}
|
|
71
94
|
|
|
72
|
-
export function
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
95
|
+
export function summarizeTokenForm(form: URLSearchParams): OauthTokenFormSummary {
|
|
96
|
+
return {
|
|
97
|
+
grantType: form.get('grant_type') ?? '',
|
|
98
|
+
clientId: form.get('client_id') ?? '',
|
|
99
|
+
redirectUri: form.get('redirect_uri') ?? '',
|
|
100
|
+
hasCode: !!form.get('code'),
|
|
101
|
+
hasCodeVerifier: !!form.get('code_verifier'),
|
|
102
|
+
hasRefreshToken: !!form.get('refresh_token'),
|
|
103
|
+
hasClientAssertion: !!form.get('client_assertion'),
|
|
104
|
+
clientAssertionType: form.get('client_assertion_type'),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function logOauth(request: Request, details: OauthLogDetails): void {
|
|
77
109
|
const url = new URL(request.url);
|
|
78
110
|
const record = {
|
|
79
111
|
level: details.outcome === 'ok' ? 'info' : 'error',
|
|
80
|
-
type:
|
|
81
|
-
stage,
|
|
112
|
+
type: `oauth_${details.endpoint}`,
|
|
113
|
+
stage: details.stage,
|
|
82
114
|
outcome: details.outcome,
|
|
83
115
|
requestId: details.requestId ?? null,
|
|
84
116
|
method: request.method,
|
|
@@ -97,3 +129,12 @@ export function logOauthPar(
|
|
|
97
129
|
console.error(JSON.stringify(record));
|
|
98
130
|
}
|
|
99
131
|
}
|
|
132
|
+
|
|
133
|
+
// Backward-compatible alias for PAR call sites.
|
|
134
|
+
export function logOauthPar(
|
|
135
|
+
stage: OauthParStage,
|
|
136
|
+
request: Request,
|
|
137
|
+
details: Omit<OauthLogDetails, 'endpoint' | 'stage'>,
|
|
138
|
+
): void {
|
|
139
|
+
logOauth(request, { ...details, endpoint: 'par', stage });
|
|
140
|
+
}
|
package/src/pages/oauth/token.ts
CHANGED
|
@@ -15,15 +15,46 @@ import {
|
|
|
15
15
|
storeRefreshToken,
|
|
16
16
|
updateOAuthSessionCurrent,
|
|
17
17
|
} from '../../db/account';
|
|
18
|
+
import {
|
|
19
|
+
logOauth,
|
|
20
|
+
summarizeTokenForm,
|
|
21
|
+
type OauthTokenFormSummary,
|
|
22
|
+
type OauthTokenStage,
|
|
23
|
+
} from '../../lib/oauth/observability';
|
|
18
24
|
|
|
19
25
|
export const prerender = false;
|
|
20
26
|
|
|
21
27
|
export async function POST({ locals, request }: APIContext) {
|
|
22
28
|
const { env } = locals.runtime;
|
|
29
|
+
const requestId = (locals as { requestId?: string }).requestId ?? null;
|
|
30
|
+
|
|
31
|
+
let formSummary: OauthTokenFormSummary | null = null;
|
|
32
|
+
let clientId: string | null = null;
|
|
33
|
+
|
|
34
|
+
const log = (
|
|
35
|
+
stage: OauthTokenStage,
|
|
36
|
+
extra: { error?: unknown; outcome?: 'ok' | 'error' } = {},
|
|
37
|
+
) =>
|
|
38
|
+
logOauth(request, {
|
|
39
|
+
endpoint: 'token',
|
|
40
|
+
stage,
|
|
41
|
+
outcome: extra.outcome ?? (extra.error !== undefined ? 'error' : 'ok'),
|
|
42
|
+
requestId,
|
|
43
|
+
error: extra.error,
|
|
44
|
+
clientId,
|
|
45
|
+
form: formSummary as Record<string, unknown> | null,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const fail = (stage: OauthTokenStage, code: string, desc: string): Response => {
|
|
49
|
+
log(stage, { error: new Error(desc) });
|
|
50
|
+
return jsonError(code, desc);
|
|
51
|
+
};
|
|
23
52
|
|
|
24
53
|
try {
|
|
25
54
|
const ver = await verifyDpop(env, request, { consumeJti: false, requireNonce: false });
|
|
26
55
|
const form = new URLSearchParams(await request.text());
|
|
56
|
+
formSummary = summarizeTokenForm(form);
|
|
57
|
+
clientId = form.get('client_id') || null;
|
|
27
58
|
const grant_type = form.get('grant_type') || '';
|
|
28
59
|
const issuer = publicPdsOrigin(env, request);
|
|
29
60
|
|
|
@@ -33,24 +64,28 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
33
64
|
const redirect_uri = form.get('redirect_uri') || '';
|
|
34
65
|
const code_verifier = form.get('code_verifier') || '';
|
|
35
66
|
if (!code || !client_id || !redirect_uri || !code_verifier) {
|
|
36
|
-
return
|
|
67
|
+
return fail('auth_code', 'invalid_request', 'Missing parameters');
|
|
37
68
|
}
|
|
38
69
|
|
|
39
70
|
const rec = await consumeCode(env, code);
|
|
40
|
-
if (!rec) return
|
|
41
|
-
if (rec.client_id !== client_id) return
|
|
42
|
-
if (rec.redirect_uri !== redirect_uri) return
|
|
71
|
+
if (!rec) return fail('auth_code', 'invalid_grant', 'Invalid or used code');
|
|
72
|
+
if (rec.client_id !== client_id) return fail('auth_code', 'invalid_grant', 'client_id mismatch');
|
|
73
|
+
if (rec.redirect_uri !== redirect_uri) return fail('auth_code', 'invalid_grant', 'redirect_uri mismatch');
|
|
43
74
|
|
|
44
75
|
const expected = await sha256b64url(code_verifier);
|
|
45
|
-
if (expected !== rec.code_challenge) return
|
|
46
|
-
if (ver.jkt !== rec.dpopJkt) return
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
})
|
|
76
|
+
if (expected !== rec.code_challenge) return fail('auth_code', 'invalid_grant', 'PKCE verification failed');
|
|
77
|
+
if (ver.jkt !== rec.dpopJkt) return fail('auth_code', 'invalid_dpop', 'DPoP key mismatch');
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await requireStoredClientAuthentication(env, client_id, issuer, form, {
|
|
81
|
+
method: rec.clientAuthMethod,
|
|
82
|
+
keyId: rec.clientAuthKeyId ?? null,
|
|
83
|
+
});
|
|
84
|
+
} catch (error) {
|
|
85
|
+
const wrapped = new Error(`Client authentication failed: ${errorMessage(error) ?? error}`);
|
|
86
|
+
log('client_auth', { error: wrapped });
|
|
87
|
+
throw wrapped;
|
|
88
|
+
}
|
|
54
89
|
await consumeDpopVerificationJti(env, ver);
|
|
55
90
|
|
|
56
91
|
const sessionId = crypto.randomUUID().replace(/-/g, '');
|
|
@@ -91,6 +126,7 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
91
126
|
});
|
|
92
127
|
|
|
93
128
|
const expires_in = accessExpiresIn(accessPayload);
|
|
129
|
+
log('success', { outcome: 'ok' });
|
|
94
130
|
return tokenResponse({
|
|
95
131
|
access_token: accessJwt,
|
|
96
132
|
token_type: 'DPoP',
|
|
@@ -105,41 +141,45 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
105
141
|
const refresh_token = form.get('refresh_token') || '';
|
|
106
142
|
const client_id = form.get('client_id') || '';
|
|
107
143
|
if (!refresh_token || !client_id) {
|
|
108
|
-
return
|
|
144
|
+
return fail('refresh_token', 'invalid_request', 'Missing refresh_token or client_id');
|
|
109
145
|
}
|
|
110
146
|
|
|
111
147
|
const verification = await verifyRefreshToken(env, refresh_token).catch(() => null);
|
|
112
|
-
if (!verification || !verification.decoded) return
|
|
148
|
+
if (!verification || !verification.decoded) return fail('refresh_token', 'invalid_grant', 'Invalid refresh token');
|
|
113
149
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
114
|
-
if (verification.decoded.exp <= nowSec) return
|
|
150
|
+
if (verification.decoded.exp <= nowSec) return fail('refresh_token', 'invalid_grant', 'Expired refresh token');
|
|
115
151
|
|
|
116
152
|
const stored = await getRefreshToken(env, verification.decoded.jti);
|
|
117
153
|
if (!stored || stored.tokenKind !== 'oauth' || !stored.oauthSessionId) {
|
|
118
|
-
return
|
|
154
|
+
return fail('refresh_token', 'invalid_grant', 'Refresh token revoked');
|
|
119
155
|
}
|
|
120
156
|
const session = await getOAuthSession(env, stored.oauthSessionId);
|
|
121
157
|
if (!session || session.revokedAt || session.expiresAt <= nowSec) {
|
|
122
|
-
return
|
|
158
|
+
return fail('refresh_token', 'invalid_grant', 'OAuth session revoked');
|
|
123
159
|
}
|
|
124
160
|
|
|
125
161
|
if (stored.revokedAt || stored.nextId || stored.id !== session.currentRefreshTokenId) {
|
|
126
162
|
await revokeOAuthSession(env, session.id, nowSec);
|
|
127
|
-
return
|
|
163
|
+
return fail('refresh_token', 'invalid_grant', 'Refresh token replayed');
|
|
128
164
|
}
|
|
129
|
-
if (stored.expiresAt <= nowSec) return
|
|
165
|
+
if (stored.expiresAt <= nowSec) return fail('refresh_token', 'invalid_grant', 'Expired refresh token');
|
|
130
166
|
if (stored.did !== verification.decoded.sub || stored.did !== session.did) {
|
|
131
167
|
await revokeOAuthSession(env, session.id, nowSec);
|
|
132
|
-
return
|
|
168
|
+
return fail('refresh_token', 'invalid_grant', 'Subject mismatch');
|
|
169
|
+
}
|
|
170
|
+
if (client_id !== session.clientId) return fail('refresh_token', 'invalid_grant', 'client_id mismatch');
|
|
171
|
+
if (ver.jkt !== session.dpopJkt) return fail('refresh_token', 'invalid_dpop', 'DPoP key mismatch');
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
await requireStoredClientAuthentication(env, client_id, issuer, form, {
|
|
175
|
+
method: session.clientAuthMethod,
|
|
176
|
+
keyId: session.clientAuthKeyId,
|
|
177
|
+
});
|
|
178
|
+
} catch (error) {
|
|
179
|
+
const wrapped = new Error(`Client authentication failed: ${errorMessage(error) ?? error}`);
|
|
180
|
+
log('client_auth', { error: wrapped });
|
|
181
|
+
throw wrapped;
|
|
133
182
|
}
|
|
134
|
-
if (client_id !== session.clientId) return jsonError('invalid_grant', 'client_id mismatch');
|
|
135
|
-
if (ver.jkt !== session.dpopJkt) return jsonError('invalid_dpop', 'DPoP key mismatch');
|
|
136
|
-
|
|
137
|
-
await requireStoredClientAuthentication(env, client_id, issuer, form, {
|
|
138
|
-
method: session.clientAuthMethod,
|
|
139
|
-
keyId: session.clientAuthKeyId,
|
|
140
|
-
}).catch((error) => {
|
|
141
|
-
throw new Error(`Client authentication failed: ${errorMessage(error) ?? error}`);
|
|
142
|
-
});
|
|
143
183
|
await consumeDpopVerificationJti(env, ver);
|
|
144
184
|
|
|
145
185
|
const accessJti = crypto.randomUUID().replace(/-/g, '');
|
|
@@ -175,9 +215,10 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
175
215
|
});
|
|
176
216
|
} catch {
|
|
177
217
|
await revokeOAuthSession(env, session.id, nowSec);
|
|
178
|
-
return
|
|
218
|
+
return fail('refresh_token', 'invalid_grant', 'Refresh token replayed');
|
|
179
219
|
}
|
|
180
220
|
|
|
221
|
+
log('success', { outcome: 'ok' });
|
|
181
222
|
return tokenResponse({
|
|
182
223
|
access_token: accessJwt,
|
|
183
224
|
token_type: 'DPoP',
|
|
@@ -188,9 +229,13 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
188
229
|
}, await getAuthzNonce(env));
|
|
189
230
|
}
|
|
190
231
|
|
|
191
|
-
return
|
|
232
|
+
return fail('unsupported_grant', 'unsupported_grant_type', 'grant_type must be authorization_code or refresh_token');
|
|
192
233
|
} catch (e) {
|
|
193
|
-
if (e instanceof DpopNonceError)
|
|
234
|
+
if (e instanceof DpopNonceError) {
|
|
235
|
+
log('dpop', { error: e });
|
|
236
|
+
return dpopErrorResponse(env, e);
|
|
237
|
+
}
|
|
238
|
+
log('outer', { error: e });
|
|
194
239
|
return jsonError('invalid_request', errorMessage(e) ?? 'Unknown error');
|
|
195
240
|
}
|
|
196
241
|
}
|