@alteran/astro 0.6.3 → 0.7.1
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/README.md +11 -0
- package/index.js +8 -0
- package/migrations/0009_oauth_session_state.sql +31 -0
- package/migrations/meta/0009_snapshot.json +749 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +2 -1
- package/src/db/account.ts +134 -1
- package/src/db/schema.ts +31 -0
- package/src/lib/appview/proxy.ts +11 -8
- package/src/lib/auth.ts +34 -3
- package/src/lib/jwt.ts +4 -0
- package/src/lib/oauth/as-keys.ts +29 -0
- package/src/lib/oauth/clients.ts +453 -24
- package/src/lib/oauth/consent.ts +180 -0
- package/src/lib/oauth/dpop.ts +39 -5
- package/src/lib/oauth/resource.ts +93 -21
- package/src/lib/oauth/store.ts +64 -7
- package/src/lib/refresh-session.ts +16 -0
- package/src/lib/session-tokens.ts +33 -5
- package/src/lib/token-cleanup.ts +4 -2
- package/src/lib/util.ts +0 -1
- package/src/pages/.well-known/oauth-authorization-server.ts +15 -3
- package/src/pages/.well-known/oauth-protected-resource.ts +8 -4
- package/src/pages/oauth/authorize.ts +31 -52
- package/src/pages/oauth/consent.ts +163 -66
- package/src/pages/oauth/jwks.ts +15 -0
- package/src/pages/oauth/par.ts +34 -56
- package/src/pages/oauth/revoke.ts +75 -0
- package/src/pages/oauth/token.ts +148 -89
- package/src/pages/xrpc/[...nsid].ts +7 -6
- package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +3 -4
- package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +3 -4
- package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +3 -4
- package/src/pages/xrpc/chat.bsky.convo.getLog.ts +3 -4
- package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +3 -4
- package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +3 -4
- package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +3 -4
- package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +3 -4
- package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +3 -4
- package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +3 -4
- package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +3 -4
- package/src/pages/xrpc/com.atproto.server.deleteSession.ts +28 -9
- package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -4
- package/types/env.d.ts +1 -0
package/src/lib/token-cleanup.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Env } from '../env';
|
|
2
|
-
import { cleanupExpiredRefreshTokens } from '../db/account';
|
|
2
|
+
import { cleanupExpiredOAuthReplaySecrets, cleanupExpiredRefreshTokens } from '../db/account';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Clean up expired tokens from the revocation table
|
|
@@ -7,7 +7,9 @@ import { cleanupExpiredRefreshTokens } from '../db/account';
|
|
|
7
7
|
*/
|
|
8
8
|
export async function cleanupExpiredTokens(env: Env): Promise<number> {
|
|
9
9
|
const now = Math.floor(Date.now() / 1000);
|
|
10
|
-
|
|
10
|
+
const refreshTokens = await cleanupExpiredRefreshTokens(env, now);
|
|
11
|
+
const oauthReplayEntries = await cleanupExpiredOAuthReplaySecrets(env, now);
|
|
12
|
+
return refreshTokens + oauthReplayEntries;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
/**
|
package/src/lib/util.ts
CHANGED
|
@@ -31,7 +31,6 @@ export function bearerToken(request: Request): string | null {
|
|
|
31
31
|
const auth = request.headers.get('authorization');
|
|
32
32
|
if (!auth) return null;
|
|
33
33
|
if (auth.startsWith('Bearer ')) return auth.slice(7);
|
|
34
|
-
if (auth.startsWith('DPoP ')) return auth.slice(5);
|
|
35
34
|
return null;
|
|
36
35
|
}
|
|
37
36
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
2
|
import { withCache, CACHE_CONFIGS } from '../../lib/cache';
|
|
3
|
+
import { publicPdsOrigin } from '../../lib/oauth/consent';
|
|
3
4
|
|
|
4
5
|
export const prerender = false;
|
|
5
6
|
|
|
@@ -8,19 +9,30 @@ export async function GET({ locals, request }: APIContext) {
|
|
|
8
9
|
return withCache(
|
|
9
10
|
request,
|
|
10
11
|
async () => {
|
|
11
|
-
const
|
|
12
|
-
const origin = `${url.protocol}//${url.host}`;
|
|
12
|
+
const origin = publicPdsOrigin(env, request);
|
|
13
13
|
const json = {
|
|
14
14
|
issuer: origin,
|
|
15
15
|
pushed_authorization_request_endpoint: `${origin}/oauth/par`,
|
|
16
16
|
authorization_endpoint: `${origin}/oauth/authorize`,
|
|
17
17
|
token_endpoint: `${origin}/oauth/token`,
|
|
18
|
-
|
|
18
|
+
jwks_uri: `${origin}/oauth/jwks`,
|
|
19
|
+
revocation_endpoint: `${origin}/oauth/revoke`,
|
|
20
|
+
scopes_supported: ['atproto', 'transition:generic'],
|
|
19
21
|
response_types_supported: ['code'],
|
|
22
|
+
response_modes_supported: ['query'],
|
|
20
23
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
21
24
|
code_challenge_methods_supported: ['S256'],
|
|
22
25
|
token_endpoint_auth_methods_supported: ['none', 'private_key_jwt'],
|
|
26
|
+
token_endpoint_auth_signing_alg_values_supported: ['ES256'],
|
|
23
27
|
dpop_signing_alg_values_supported: ['ES256'],
|
|
28
|
+
subject_types_supported: ['public'],
|
|
29
|
+
prompt_values_supported: ['none', 'consent', 'login'],
|
|
30
|
+
require_pushed_authorization_requests: true,
|
|
31
|
+
request_parameter_supported: false,
|
|
32
|
+
request_uri_parameter_supported: true,
|
|
33
|
+
authorization_response_iss_parameter_supported: true,
|
|
34
|
+
client_id_metadata_document_supported: true,
|
|
35
|
+
protected_resources: [origin],
|
|
24
36
|
};
|
|
25
37
|
return new Response(JSON.stringify(json, null, 2), {
|
|
26
38
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
2
|
import { withCache, CACHE_CONFIGS } from '../../lib/cache';
|
|
3
|
+
import { publicPdsOrigin } from '../../lib/oauth/consent';
|
|
3
4
|
|
|
4
5
|
export const prerender = false;
|
|
5
6
|
|
|
6
|
-
export async function GET({ request }: APIContext) {
|
|
7
|
+
export async function GET({ locals, request }: APIContext) {
|
|
8
|
+
const { env } = locals.runtime;
|
|
7
9
|
return withCache(
|
|
8
10
|
request,
|
|
9
11
|
async () => {
|
|
10
|
-
const
|
|
11
|
-
const origin = `${url.protocol}//${url.host}`;
|
|
12
|
+
const origin = publicPdsOrigin(env, request);
|
|
12
13
|
const json = {
|
|
14
|
+
resource: origin,
|
|
13
15
|
authorization_servers: [origin],
|
|
16
|
+
bearer_methods_supported: ['header'],
|
|
17
|
+
scopes_supported: ['atproto', 'transition:generic'],
|
|
18
|
+
resource_documentation: `${origin}/.well-known/oauth-protected-resource`,
|
|
14
19
|
};
|
|
15
20
|
return new Response(JSON.stringify(json, null, 2), {
|
|
16
21
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -19,4 +24,3 @@ export async function GET({ request }: APIContext) {
|
|
|
19
24
|
CACHE_CONFIGS.WELL_KNOWN,
|
|
20
25
|
);
|
|
21
26
|
}
|
|
22
|
-
|
|
@@ -1,78 +1,57 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
|
-
import {
|
|
2
|
+
import { deletePar, loadPar } from '../../lib/oauth/store';
|
|
3
|
+
import { loginHintMatchesSingleUser, publicPdsOrigin, redirectWithOAuthError } from '../../lib/oauth/consent';
|
|
3
4
|
|
|
4
5
|
export const prerender = false;
|
|
5
6
|
|
|
6
|
-
function parseRequestUri(
|
|
7
|
+
function parseRequestUri(v: string | null): string | null {
|
|
7
8
|
const p = 'urn:ietf:params:oauth:request_uri:';
|
|
8
|
-
if (!
|
|
9
|
-
const id =
|
|
9
|
+
if (!v || !v.startsWith(p)) return null;
|
|
10
|
+
const id = v.slice(p.length);
|
|
10
11
|
return /^[A-Za-z0-9]+$/.test(id) ? id : null;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export async function GET({ locals, request }: APIContext) {
|
|
14
15
|
const { env } = locals.runtime;
|
|
15
16
|
const url = new URL(request.url);
|
|
16
|
-
const
|
|
17
|
+
const issuer = publicPdsOrigin(env, request);
|
|
17
18
|
const client_id = url.searchParams.get('client_id') || '';
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
19
|
+
const request_uri = url.searchParams.get('request_uri');
|
|
20
|
+
const frontChannelPrompt = (url.searchParams.get('prompt') || '').split(' ').filter(Boolean);
|
|
21
21
|
const id = parseRequestUri(request_uri);
|
|
22
22
|
if (!id) {
|
|
23
|
-
return new Response(
|
|
23
|
+
return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'invalid request_uri' }), {
|
|
24
|
+
status: 400,
|
|
25
|
+
headers: { 'Content-Type': 'application/json' },
|
|
26
|
+
});
|
|
24
27
|
}
|
|
28
|
+
|
|
25
29
|
const par = await loadPar(env, id);
|
|
26
30
|
if (!par) {
|
|
27
|
-
return new Response(
|
|
31
|
+
return new Response(JSON.stringify({ error: 'invalid_request_uri' }), {
|
|
32
|
+
status: 400,
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
});
|
|
28
35
|
}
|
|
36
|
+
|
|
29
37
|
if (client_id && client_id !== par.client_id) {
|
|
30
|
-
|
|
38
|
+
await deletePar(env, id);
|
|
39
|
+
return redirectWithOAuthError(par.redirect_uri, 'invalid_request', par.state, issuer, 'client_id mismatch');
|
|
31
40
|
}
|
|
32
41
|
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
redirectDeny.searchParams.set('error', 'access_denied');
|
|
37
|
-
return new Response(null, { status: 302, headers: { Location: redirectDeny.toString() } });
|
|
42
|
+
if (!(await loginHintMatchesSingleUser(env, par.login_hint))) {
|
|
43
|
+
await deletePar(env, id);
|
|
44
|
+
return redirectWithOAuthError(par.redirect_uri, 'login_required', par.state, issuer, 'login_hint does not match this PDS account');
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
consentUrl.searchParams.set('client_id', par.client_id);
|
|
45
|
-
return new Response(null, { status: 302, headers: { Location: consentUrl.toString() } });
|
|
47
|
+
const parPrompt = (par.prompt || '').split(' ').filter(Boolean);
|
|
48
|
+
if (frontChannelPrompt.includes('none') || parPrompt.includes('none')) {
|
|
49
|
+
await deletePar(env, id);
|
|
50
|
+
return redirectWithOAuthError(par.redirect_uri, 'login_required', par.state, issuer);
|
|
46
51
|
}
|
|
47
52
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
// Issue a short-lived authorization code
|
|
53
|
-
const code = crypto.randomUUID().replace(/-/g, '');
|
|
54
|
-
const now = Math.floor(Date.now() / 1000);
|
|
55
|
-
await saveCode(env, code, {
|
|
56
|
-
code,
|
|
57
|
-
client_id: par.client_id,
|
|
58
|
-
redirect_uri: par.redirect_uri,
|
|
59
|
-
code_challenge: par.code_challenge,
|
|
60
|
-
scope: par.scope,
|
|
61
|
-
dpopJkt: par.dpopJkt,
|
|
62
|
-
did,
|
|
63
|
-
createdAt: now,
|
|
64
|
-
expiresAt: now + 600, // 10 minutes
|
|
65
|
-
used: false,
|
|
66
|
-
});
|
|
67
|
-
await deletePar(env, id);
|
|
68
|
-
|
|
69
|
-
const redirect = new URL(par.redirect_uri);
|
|
70
|
-
redirect.searchParams.set('state', par.state);
|
|
71
|
-
redirect.searchParams.set('iss', `${url.protocol}//${url.host}`);
|
|
72
|
-
redirect.searchParams.set('code', code);
|
|
73
|
-
|
|
74
|
-
return new Response(null, {
|
|
75
|
-
status: 302,
|
|
76
|
-
headers: { Location: redirect.toString() },
|
|
77
|
-
});
|
|
53
|
+
const consentUrl = new URL('/oauth/consent', issuer);
|
|
54
|
+
consentUrl.searchParams.set('request_uri', request_uri ?? '');
|
|
55
|
+
consentUrl.searchParams.set('client_id', par.client_id);
|
|
56
|
+
return Response.redirect(consentUrl.toString(), 302);
|
|
78
57
|
}
|
|
@@ -1,80 +1,177 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
|
-
import { loadPar } from '../../lib/oauth/store';
|
|
2
|
+
import { deletePar, loadPar, saveCode, saveConsent, consumeConsent } from '../../lib/oauth/store';
|
|
3
|
+
import {
|
|
4
|
+
authenticateSingleUserPassword,
|
|
5
|
+
checkConsentPasswordLockout,
|
|
6
|
+
clearConsentPasswordFailures,
|
|
7
|
+
htmlEscape,
|
|
8
|
+
isSameOriginPost,
|
|
9
|
+
loginHintMatchesSingleUser,
|
|
10
|
+
publicPdsOrigin,
|
|
11
|
+
reserveConsentPasswordAttempt,
|
|
12
|
+
redirectWithCode,
|
|
13
|
+
redirectWithOAuthError,
|
|
14
|
+
} from '../../lib/oauth/consent';
|
|
3
15
|
import { fetchClientMetadata } from '../../lib/oauth/clients';
|
|
4
16
|
|
|
5
17
|
export const prerender = false;
|
|
6
18
|
|
|
7
|
-
function
|
|
19
|
+
function parseRequestUri(v: string | null): string | null {
|
|
20
|
+
const p = 'urn:ietf:params:oauth:request_uri:';
|
|
21
|
+
if (!v || !v.startsWith(p)) return null;
|
|
22
|
+
const id = v.slice(p.length);
|
|
23
|
+
return /^[A-Za-z0-9]+$/.test(id) ? id : null;
|
|
24
|
+
}
|
|
8
25
|
|
|
9
26
|
export async function GET({ locals, request }: APIContext) {
|
|
27
|
+
const { env } = locals.runtime;
|
|
10
28
|
const url = new URL(request.url);
|
|
11
|
-
const request_uri = url.searchParams.get('request_uri')
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const par = await loadPar(
|
|
17
|
-
if (!par)
|
|
18
|
-
|
|
29
|
+
const request_uri = url.searchParams.get('request_uri');
|
|
30
|
+
const id = parseRequestUri(request_uri);
|
|
31
|
+
if (!id) {
|
|
32
|
+
return new Response('Invalid OAuth request', { status: 400 });
|
|
33
|
+
}
|
|
34
|
+
const par = await loadPar(env, id);
|
|
35
|
+
if (!par) {
|
|
36
|
+
return new Response('OAuth request expired', { status: 400 });
|
|
37
|
+
}
|
|
38
|
+
const issuer = publicPdsOrigin(env, request);
|
|
39
|
+
if ((par.prompt || '').split(' ').filter(Boolean).includes('none')) {
|
|
40
|
+
await deletePar(env, id);
|
|
41
|
+
return redirectWithOAuthError(par.redirect_uri, 'login_required', par.state, issuer);
|
|
42
|
+
}
|
|
43
|
+
if (!(await loginHintMatchesSingleUser(env, par.login_hint))) {
|
|
44
|
+
await deletePar(env, id);
|
|
45
|
+
return redirectWithOAuthError(par.redirect_uri, 'login_required', par.state, issuer, 'login_hint does not match this PDS account');
|
|
46
|
+
}
|
|
19
47
|
|
|
20
|
-
let
|
|
48
|
+
let clientName = par.client_id;
|
|
21
49
|
try {
|
|
22
|
-
meta = await fetchClientMetadata(par.client_id);
|
|
50
|
+
const meta = await fetchClientMetadata(env, par.client_id);
|
|
51
|
+
if (typeof (meta as any).client_name === 'string') clientName = (meta as any).client_name;
|
|
23
52
|
} catch {
|
|
24
|
-
//
|
|
53
|
+
// The PAR step already validated metadata; keep consent rendering available
|
|
54
|
+
// if the client metadata endpoint is temporarily unavailable.
|
|
25
55
|
}
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
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
|
-
<div style="color:#555; font-size: 12px;">${esc(par.client_id)}</div>
|
|
64
|
-
</div>
|
|
65
|
-
</div>
|
|
66
|
-
<p style="margin-top:1rem;">This app is requesting:</p>
|
|
67
|
-
<div>
|
|
68
|
-
${scopes.map((s) => `<span class="scope">${esc(s)}</span>`).join(' ')}
|
|
69
|
-
</div>
|
|
70
|
-
<div class="actions">
|
|
71
|
-
<a class="btn primary" href="${allowUrl.toString()}">Allow</a>
|
|
72
|
-
<a class="btn secondary" href="${denyUrl.toString()}">Deny</a>
|
|
73
|
-
</div>
|
|
74
|
-
</div>
|
|
75
|
-
</body>
|
|
76
|
-
</html>`;
|
|
77
|
-
|
|
78
|
-
return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
|
56
|
+
|
|
57
|
+
const csrf = crypto.randomUUID().replace(/-/g, '');
|
|
58
|
+
await saveConsent(env, id, csrf, Math.floor(Date.now() / 1000) + 300);
|
|
59
|
+
|
|
60
|
+
const body = `<!doctype html>
|
|
61
|
+
<html lang="en">
|
|
62
|
+
<head>
|
|
63
|
+
<meta charset="utf-8" />
|
|
64
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
65
|
+
<title>Authorize ${htmlEscape(clientName)}</title>
|
|
66
|
+
<style>
|
|
67
|
+
body { font-family: system-ui, sans-serif; max-width: 38rem; margin: 4rem auto; padding: 0 1rem; line-height: 1.5; }
|
|
68
|
+
label, input, button { display: block; width: 100%; box-sizing: border-box; }
|
|
69
|
+
input { margin: .4rem 0 1rem; padding: .7rem; }
|
|
70
|
+
button { margin-top: .75rem; padding: .75rem; }
|
|
71
|
+
</style>
|
|
72
|
+
</head>
|
|
73
|
+
<body>
|
|
74
|
+
<h1>Authorize ${htmlEscape(clientName)}</h1>
|
|
75
|
+
<p>${htmlEscape(clientName)} is requesting access to ${htmlEscape(par.scope)}.</p>
|
|
76
|
+
<form method="post" action="/oauth/consent">
|
|
77
|
+
<input type="hidden" name="request_uri" value="${htmlEscape(request_uri ?? '')}" />
|
|
78
|
+
<input type="hidden" name="client_id" value="${htmlEscape(par.client_id)}" />
|
|
79
|
+
<input type="hidden" name="csrf" value="${csrf}" />
|
|
80
|
+
<label>Password<input name="password" type="password" autocomplete="current-password" required /></label>
|
|
81
|
+
<button name="decision" value="allow" type="submit">Allow</button>
|
|
82
|
+
<button name="decision" value="deny" type="submit" formnovalidate>Deny</button>
|
|
83
|
+
</form>
|
|
84
|
+
</body>
|
|
85
|
+
</html>`;
|
|
86
|
+
|
|
87
|
+
return new Response(body, {
|
|
88
|
+
headers: {
|
|
89
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
90
|
+
'Cache-Control': 'no-store',
|
|
91
|
+
},
|
|
92
|
+
});
|
|
79
93
|
}
|
|
80
94
|
|
|
95
|
+
export async function POST({ locals, request }: APIContext) {
|
|
96
|
+
const { env } = locals.runtime;
|
|
97
|
+
if (!isSameOriginPost(request)) {
|
|
98
|
+
return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'same-origin POST required' }), {
|
|
99
|
+
status: 403,
|
|
100
|
+
headers: { 'Content-Type': 'application/json' },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const form = new URLSearchParams(await request.text());
|
|
105
|
+
const request_uri = form.get('request_uri');
|
|
106
|
+
const id = parseRequestUri(request_uri);
|
|
107
|
+
const csrf = form.get('csrf') || '';
|
|
108
|
+
if (!id || !(await consumeConsent(env, id, csrf))) {
|
|
109
|
+
return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'invalid csrf' }), {
|
|
110
|
+
status: 403,
|
|
111
|
+
headers: { 'Content-Type': 'application/json' },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const issuer = publicPdsOrigin(env, request);
|
|
116
|
+
const par = await loadPar(env, id);
|
|
117
|
+
if (!par) {
|
|
118
|
+
return new Response(JSON.stringify({ error: 'invalid_request_uri' }), {
|
|
119
|
+
status: 400,
|
|
120
|
+
headers: { 'Content-Type': 'application/json' },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if ((form.get('client_id') || '') !== par.client_id) {
|
|
125
|
+
await deletePar(env, id);
|
|
126
|
+
return redirectWithOAuthError(par.redirect_uri, 'invalid_request', par.state, issuer, 'client_id mismatch');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if ((par.prompt || '').split(' ').filter(Boolean).includes('none')) {
|
|
130
|
+
await deletePar(env, id);
|
|
131
|
+
return redirectWithOAuthError(par.redirect_uri, 'login_required', par.state, issuer);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!(await loginHintMatchesSingleUser(env, par.login_hint))) {
|
|
135
|
+
await deletePar(env, id);
|
|
136
|
+
return redirectWithOAuthError(par.redirect_uri, 'login_required', par.state, issuer, 'login_hint does not match this PDS account');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const decision = form.get('decision') || '';
|
|
140
|
+
if (decision !== 'allow') {
|
|
141
|
+
await deletePar(env, id);
|
|
142
|
+
return redirectWithOAuthError(par.redirect_uri, 'access_denied', par.state, issuer);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const lockout = await checkConsentPasswordLockout(env, request);
|
|
146
|
+
if (lockout) return lockout;
|
|
147
|
+
const reservedAttempt = await reserveConsentPasswordAttempt(env, request);
|
|
148
|
+
if (reservedAttempt) {
|
|
149
|
+
await deletePar(env, id);
|
|
150
|
+
return reservedAttempt;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const account = await authenticateSingleUserPassword(env, form.get('password') || '');
|
|
154
|
+
if (!account) {
|
|
155
|
+
await deletePar(env, id);
|
|
156
|
+
return redirectWithOAuthError(par.redirect_uri, 'access_denied', par.state, issuer, 'invalid credentials');
|
|
157
|
+
}
|
|
158
|
+
await clearConsentPasswordFailures(env, request);
|
|
159
|
+
|
|
160
|
+
const code = crypto.randomUUID().replace(/-/g, '');
|
|
161
|
+
const now = Math.floor(Date.now() / 1000);
|
|
162
|
+
await saveCode(env, code, {
|
|
163
|
+
code,
|
|
164
|
+
client_id: par.client_id,
|
|
165
|
+
redirect_uri: par.redirect_uri,
|
|
166
|
+
code_challenge: par.code_challenge,
|
|
167
|
+
scope: par.scope,
|
|
168
|
+
dpopJkt: par.dpopJkt,
|
|
169
|
+
clientAuthMethod: par.clientAuthMethod,
|
|
170
|
+
clientAuthKeyId: par.clientAuthKeyId ?? null,
|
|
171
|
+
did: account.did,
|
|
172
|
+
createdAt: now,
|
|
173
|
+
expiresAt: now + 300,
|
|
174
|
+
});
|
|
175
|
+
await deletePar(env, id);
|
|
176
|
+
return redirectWithCode(par.redirect_uri, code, par.state, issuer);
|
|
177
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { getAuthorizationServerPublicJwk } from '../../lib/oauth/as-keys';
|
|
3
|
+
|
|
4
|
+
export const prerender = false;
|
|
5
|
+
|
|
6
|
+
export async function GET({ locals }: APIContext) {
|
|
7
|
+
const { env } = locals.runtime;
|
|
8
|
+
const jwk = await getAuthorizationServerPublicJwk(env);
|
|
9
|
+
return new Response(JSON.stringify({ keys: [jwk] }, null, 2), {
|
|
10
|
+
headers: {
|
|
11
|
+
'Content-Type': 'application/json',
|
|
12
|
+
'Cache-Control': 'public, max-age=300',
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
}
|
package/src/pages/oauth/par.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
2
|
import { errorMessage } from '../../lib/errors';
|
|
3
|
-
import { getAuthzNonce, setDpopNonceHeader, verifyDpop, dpopErrorResponse } from '../../lib/oauth/dpop';
|
|
3
|
+
import { consumeDpopVerificationJti, getAuthzNonce, setDpopNonceHeader, verifyDpop, dpopErrorResponse } from '../../lib/oauth/dpop';
|
|
4
4
|
import { DpopNonceError } from '../../lib/oauth/dpop-errors';
|
|
5
|
+
import { publicPdsOrigin } from '../../lib/oauth/consent';
|
|
5
6
|
import { savePar } from '../../lib/oauth/store';
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
fetchClientMetadata,
|
|
9
|
+
isSafeFetchUrl,
|
|
10
|
+
validateParRequest,
|
|
11
|
+
verifyClientAuthentication,
|
|
12
|
+
} from '../../lib/oauth/clients';
|
|
7
13
|
|
|
8
14
|
export const prerender = false;
|
|
9
15
|
|
|
@@ -12,83 +18,51 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
12
18
|
|
|
13
19
|
// Enforce DPoP with nonce; if missing or stale, return use_dpop_nonce
|
|
14
20
|
try {
|
|
15
|
-
const ver = await verifyDpop(env, request);
|
|
21
|
+
const ver = await verifyDpop(env, request, { consumeJti: false });
|
|
16
22
|
|
|
17
23
|
// Parse form-encoded body
|
|
18
24
|
const bodyText = await request.text();
|
|
19
25
|
const form = new URLSearchParams(bodyText);
|
|
20
26
|
const client_id = form.get('client_id') || '';
|
|
21
27
|
const response_type = form.get('response_type') || '';
|
|
28
|
+
const grant_type = form.get('grant_type') || undefined;
|
|
22
29
|
const redirect_uri = form.get('redirect_uri') || '';
|
|
23
30
|
const scope = form.get('scope') || '';
|
|
24
31
|
const state = form.get('state') || '';
|
|
25
32
|
const code_challenge = form.get('code_challenge') || '';
|
|
26
33
|
const code_challenge_method = form.get('code_challenge_method') || '';
|
|
27
34
|
const login_hint = form.get('login_hint') || undefined;
|
|
28
|
-
const
|
|
29
|
-
const client_assertion = form.get('client_assertion') || '';
|
|
35
|
+
const prompt = form.get('prompt') || undefined;
|
|
30
36
|
|
|
31
|
-
if (!client_id || !
|
|
32
|
-
return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'client_id must be https URL' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
33
|
-
}
|
|
34
|
-
if (response_type !== 'code') {
|
|
35
|
-
return new Response(JSON.stringify({ error: 'unsupported_response_type' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
36
|
-
}
|
|
37
|
-
if (!redirect_uri || !isHttpsUrl(redirect_uri)) {
|
|
38
|
-
return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'redirect_uri must be https URL' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
39
|
-
}
|
|
40
|
-
if (!scope || !scope.split(' ').includes('atproto')) {
|
|
41
|
-
return new Response(JSON.stringify({ error: 'invalid_scope' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
37
|
+
if (!client_id || !isSafeFetchUrl(client_id)) {
|
|
38
|
+
return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'client_id must be a safe https metadata URL' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
42
39
|
}
|
|
43
40
|
if (!state) {
|
|
44
41
|
return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'state required' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
45
42
|
}
|
|
46
|
-
if (!code_challenge || code_challenge_method !== 'S256') {
|
|
47
|
-
return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'PKCE (S256) required' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
48
|
-
}
|
|
49
43
|
|
|
50
44
|
// Fetch and validate client metadata
|
|
51
|
-
let clientMeta
|
|
45
|
+
let clientMeta;
|
|
52
46
|
try {
|
|
53
|
-
clientMeta = await fetchClientMetadata(client_id);
|
|
47
|
+
clientMeta = await fetchClientMetadata(env, client_id);
|
|
48
|
+
validateParRequest(clientMeta, {
|
|
49
|
+
response_type,
|
|
50
|
+
grant_type,
|
|
51
|
+
redirect_uri,
|
|
52
|
+
scope,
|
|
53
|
+
code_challenge,
|
|
54
|
+
code_challenge_method,
|
|
55
|
+
});
|
|
54
56
|
} catch (e) {
|
|
55
57
|
return new Response(JSON.stringify({ error: 'invalid_client', error_description: errorMessage(e) ?? 'Client metadata fetch failed' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return new Response(JSON.stringify({ error: '
|
|
64
|
-
}
|
|
65
|
-
if (clientMeta?.dpop_bound_access_tokens !== true) {
|
|
66
|
-
return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'client must require DPoP' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
67
|
-
}
|
|
68
|
-
const url = new URL(request.url);
|
|
69
|
-
const issuerOrigin = `${url.protocol}//${url.host}`;
|
|
70
|
-
const authMethod = clientMeta?.token_endpoint_auth_method;
|
|
71
|
-
if (authMethod === 'private_key_jwt') {
|
|
72
|
-
// Load JWKS (inline or via URI)
|
|
73
|
-
let jwks = clientMeta?.jwks;
|
|
74
|
-
if (!jwks && typeof clientMeta?.jwks_uri === 'string') {
|
|
75
|
-
try {
|
|
76
|
-
const response = await fetch(clientMeta.jwks_uri);
|
|
77
|
-
jwks = await response.json();
|
|
78
|
-
} catch (e) {
|
|
79
|
-
return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'Failed to fetch jwks_uri' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
if (!jwks) {
|
|
83
|
-
return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'Missing JWKS' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
84
|
-
}
|
|
85
|
-
if (client_assertion_type !== 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' || !client_assertion) {
|
|
86
|
-
return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'Missing client assertion' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
87
|
-
}
|
|
88
|
-
const ok = await verifyClientAssertion(client_id, issuerOrigin, client_assertion, jwks);
|
|
89
|
-
if (!ok) {
|
|
90
|
-
return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'Invalid client assertion' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
|
91
|
-
}
|
|
60
|
+
const issuerOrigin = publicPdsOrigin(env, request);
|
|
61
|
+
let clientAuth;
|
|
62
|
+
try {
|
|
63
|
+
clientAuth = await verifyClientAuthentication(env, client_id, issuerOrigin, clientMeta, form);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return new Response(JSON.stringify({ error: 'invalid_client', error_description: errorMessage(e) ?? 'Client authentication failed' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
|
92
66
|
}
|
|
93
67
|
|
|
94
68
|
const id = crypto.randomUUID().replace(/-/g, '');
|
|
@@ -101,16 +75,20 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
101
75
|
scope,
|
|
102
76
|
state,
|
|
103
77
|
login_hint,
|
|
78
|
+
prompt,
|
|
104
79
|
dpopJkt: ver.jkt,
|
|
80
|
+
clientAuthMethod: clientAuth.method,
|
|
81
|
+
clientAuthKeyId: clientAuth.keyId,
|
|
105
82
|
createdAt: now,
|
|
106
83
|
expiresAt: now + 300, // 5 minutes
|
|
107
84
|
};
|
|
85
|
+
await consumeDpopVerificationJti(env, ver);
|
|
108
86
|
await savePar(env, id, rec);
|
|
109
87
|
|
|
110
88
|
const request_uri = `urn:ietf:params:oauth:request_uri:${id}`;
|
|
111
89
|
const headers = new Headers({ 'Content-Type': 'application/json' });
|
|
112
90
|
setDpopNonceHeader(headers, await getAuthzNonce(env));
|
|
113
|
-
return new Response(JSON.stringify({ request_uri }), { status: 201, headers });
|
|
91
|
+
return new Response(JSON.stringify({ request_uri, expires_in: 300 }), { status: 201, headers });
|
|
114
92
|
} catch (e) {
|
|
115
93
|
if (e instanceof DpopNonceError) {
|
|
116
94
|
return dpopErrorResponse(env, e);
|