@shware/security 7.1.1 → 8.0.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 +113 -32
- package/dist/auth/attributes.cjs +4 -3
- package/dist/auth/attributes.cjs.map +1 -1
- package/dist/auth/attributes.d.cts +3 -3
- package/dist/auth/attributes.d.cts.map +1 -1
- package/dist/auth/attributes.d.mts +3 -3
- package/dist/auth/attributes.d.mts.map +1 -1
- package/dist/auth/attributes.mjs +4 -3
- package/dist/auth/attributes.mjs.map +1 -1
- package/dist/auth/desktop-codes.cjs +57 -0
- package/dist/auth/desktop-codes.cjs.map +1 -0
- package/dist/auth/desktop-codes.d.cts +24 -0
- package/dist/auth/desktop-codes.d.cts.map +1 -0
- package/dist/auth/desktop-codes.d.mts +24 -0
- package/dist/auth/desktop-codes.d.mts.map +1 -0
- package/dist/auth/desktop-codes.mjs +55 -0
- package/dist/auth/desktop-codes.mjs.map +1 -0
- package/dist/auth/email-codes.cjs +77 -0
- package/dist/auth/email-codes.cjs.map +1 -0
- package/dist/auth/email-codes.d.cts +39 -0
- package/dist/auth/email-codes.d.cts.map +1 -0
- package/dist/auth/email-codes.d.mts +39 -0
- package/dist/auth/email-codes.d.mts.map +1 -0
- package/dist/auth/email-codes.mjs +75 -0
- package/dist/auth/email-codes.mjs.map +1 -0
- package/dist/auth/error.cjs +47 -0
- package/dist/auth/error.cjs.map +1 -0
- package/dist/auth/error.d.cts +88 -0
- package/dist/auth/error.d.cts.map +1 -0
- package/dist/auth/error.d.mts +88 -0
- package/dist/auth/error.d.mts.map +1 -0
- package/dist/auth/error.mjs +45 -0
- package/dist/auth/error.mjs.map +1 -0
- package/dist/auth/index.cjs +249 -384
- package/dist/auth/index.cjs.map +1 -1
- package/dist/auth/index.d.cts +66 -55
- package/dist/auth/index.d.cts.map +1 -1
- package/dist/auth/index.d.mts +66 -55
- package/dist/auth/index.d.mts.map +1 -1
- package/dist/auth/index.mjs +252 -387
- package/dist/auth/index.mjs.map +1 -1
- package/dist/auth/respond.cjs +99 -0
- package/dist/auth/respond.cjs.map +1 -0
- package/dist/auth/respond.d.cts +39 -0
- package/dist/auth/respond.d.cts.map +1 -0
- package/dist/auth/respond.d.mts +39 -0
- package/dist/auth/respond.d.mts.map +1 -0
- package/dist/auth/respond.mjs +92 -0
- package/dist/auth/respond.mjs.map +1 -0
- package/dist/auth/types.d.cts +27 -11
- package/dist/auth/types.d.cts.map +1 -1
- package/dist/auth/types.d.mts +27 -11
- package/dist/auth/types.d.mts.map +1 -1
- package/dist/index.cjs +5 -0
- package/dist/index.d.cts +4 -1
- package/dist/index.d.mts +4 -1
- package/dist/index.mjs +3 -1
- package/dist/oauth2/error.cjs +0 -7
- package/dist/oauth2/error.cjs.map +1 -1
- package/dist/oauth2/error.d.cts +0 -3
- package/dist/oauth2/error.d.cts.map +1 -1
- package/dist/oauth2/error.d.mts +0 -3
- package/dist/oauth2/error.d.mts.map +1 -1
- package/dist/oauth2/error.mjs +1 -7
- package/dist/oauth2/error.mjs.map +1 -1
- package/dist/oauth2/provider/common.cjs +17 -3
- package/dist/oauth2/provider/common.cjs.map +1 -1
- package/dist/oauth2/provider/common.d.cts.map +1 -1
- package/dist/oauth2/provider/common.d.mts.map +1 -1
- package/dist/oauth2/provider/common.mjs +17 -3
- package/dist/oauth2/provider/common.mjs.map +1 -1
- package/dist/oauth2/provider/facebook.cjs +2 -4
- package/dist/oauth2/provider/facebook.cjs.map +1 -1
- package/dist/oauth2/provider/facebook.d.cts +0 -1
- package/dist/oauth2/provider/facebook.d.cts.map +1 -1
- package/dist/oauth2/provider/facebook.d.mts +0 -1
- package/dist/oauth2/provider/facebook.d.mts.map +1 -1
- package/dist/oauth2/provider/facebook.mjs +2 -4
- package/dist/oauth2/provider/facebook.mjs.map +1 -1
- package/dist/oauth2/provider/github.cjs +1 -1
- package/dist/oauth2/provider/github.cjs.map +1 -1
- package/dist/oauth2/provider/github.d.cts +1 -1
- package/dist/oauth2/provider/github.d.cts.map +1 -1
- package/dist/oauth2/provider/github.d.mts +1 -1
- package/dist/oauth2/provider/github.d.mts.map +1 -1
- package/dist/oauth2/provider/github.mjs +1 -1
- package/dist/oauth2/provider/github.mjs.map +1 -1
- package/dist/oauth2/provider/x.cjs +6 -4
- package/dist/oauth2/provider/x.cjs.map +1 -1
- package/dist/oauth2/provider/x.d.cts +3 -2
- package/dist/oauth2/provider/x.d.cts.map +1 -1
- package/dist/oauth2/provider/x.d.mts +3 -2
- package/dist/oauth2/provider/x.d.mts.map +1 -1
- package/dist/oauth2/provider/x.mjs +6 -4
- package/dist/oauth2/provider/x.mjs.map +1 -1
- package/dist/oauth2/types.cjs.map +1 -1
- package/dist/oauth2/types.d.cts +5 -0
- package/dist/oauth2/types.d.cts.map +1 -1
- package/dist/oauth2/types.d.mts +5 -0
- package/dist/oauth2/types.d.mts.map +1 -1
- package/dist/oauth2/types.mjs.map +1 -1
- package/dist/session/common.cjs +10 -13
- package/dist/session/common.cjs.map +1 -1
- package/dist/session/common.d.cts +7 -3
- package/dist/session/common.d.cts.map +1 -1
- package/dist/session/common.d.mts +7 -3
- package/dist/session/common.d.mts.map +1 -1
- package/dist/session/common.mjs +10 -12
- package/dist/session/common.mjs.map +1 -1
- package/dist/session/db.cjs +2 -2
- package/dist/session/db.cjs.map +1 -1
- package/dist/session/db.mjs +3 -3
- package/dist/session/db.mjs.map +1 -1
- package/dist/session/redis.cjs +6 -8
- package/dist/session/redis.cjs.map +1 -1
- package/dist/session/redis.d.cts.map +1 -1
- package/dist/session/redis.d.mts.map +1 -1
- package/dist/session/redis.mjs +7 -9
- package/dist/session/redis.mjs.map +1 -1
- package/dist/session/types.d.cts +0 -12
- package/dist/session/types.d.cts.map +1 -1
- package/dist/session/types.d.mts +0 -12
- package/dist/session/types.d.mts.map +1 -1
- package/dist/utils/valid.cjs +34 -52
- package/dist/utils/valid.cjs.map +1 -1
- package/dist/utils/valid.d.cts +8 -23
- package/dist/utils/valid.d.cts.map +1 -1
- package/dist/utils/valid.d.mts +8 -23
- package/dist/utils/valid.d.mts.map +1 -1
- package/dist/utils/valid.mjs +32 -50
- package/dist/utils/valid.mjs.map +1 -1
- package/package.json +3 -3
package/dist/auth/index.mjs
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { loginEmailSchema, sendEmailVerificationCodeSchema } from "../email/schema.mjs";
|
|
1
|
+
import { AuthenticationError } from "./error.mjs";
|
|
3
2
|
import { OAuth2Error, isKnownOAuth2ErrorType } from "../oauth2/error.mjs";
|
|
3
|
+
import { errorResponse, invalidArgument, unauthenticated } from "./respond.mjs";
|
|
4
|
+
import { MAX_ATTRIBUTES_BYTES, attributesSchema, attributesSize, bodyAttributes } from "./attributes.mjs";
|
|
5
|
+
import { loginEmailSchema, sendEmailVerificationCodeSchema } from "../email/schema.mjs";
|
|
4
6
|
import { OAuth2Client, googleOneTapSchema, oauth2RedirectQuerySchema } from "../oauth2/client.mjs";
|
|
5
7
|
import { PRINCIPAL_NAME_INDEX_NAME } from "../session/common.mjs";
|
|
6
8
|
import { deleteCookie, getCookie, param, query, setCookie } from "../utils/http.mjs";
|
|
7
9
|
import { timing } from "../utils/timing.mjs";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
+
import { parse, readForm, readJson, verifyTurnstileToken } from "../utils/valid.mjs";
|
|
11
|
+
import { DesktopCodes } from "./desktop-codes.mjs";
|
|
12
|
+
import { EmailVerificationCodes } from "./email-codes.mjs";
|
|
13
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
10
14
|
import { invariant } from "@shware/utils";
|
|
15
|
+
import { extend, literal, object, regex, string } from "zod/mini";
|
|
11
16
|
// src/auth/index.ts
|
|
12
17
|
const PKCE_FORMAT = /^[A-Za-z0-9_-]{43,128}$/;
|
|
13
18
|
const CODE_VERIFIER_FORMAT = /^[A-Za-z0-9._~-]{43,128}$/;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
/** The header a session id travels in besides the cookie, with `session.header` on. */
|
|
20
|
+
const SESSION_HEADER = "X-Auth-Token";
|
|
21
|
+
/** Seconds an OAuth2 state or nonce may be used within. */
|
|
22
|
+
const OAUTH2_PARAMETER_TTL = 600;
|
|
18
23
|
const MAX_ERROR_DESCRIPTION_LENGTH = 256;
|
|
19
24
|
/** An error code safe to put on errorUri: a registered one as is, anything else as `fallback`. */
|
|
20
25
|
function safeErrorType(error, fallback) {
|
|
@@ -41,14 +46,20 @@ function authorizationAttributes(attributes) {
|
|
|
41
46
|
return defined;
|
|
42
47
|
}
|
|
43
48
|
/** Sign in with Apple answers with `response_mode=form_post`; a charset parameter may follow. */
|
|
44
|
-
const INVALID_ATTRIBUTES = {
|
|
45
|
-
error: "invalid_request",
|
|
46
|
-
error_description: `attributes must map strings to strings, at most ${MAX_ATTRIBUTES_BYTES} bytes`
|
|
47
|
-
};
|
|
48
49
|
function isFormPost(request) {
|
|
49
50
|
const contentType = request.headers.get("content-type")?.toLowerCase();
|
|
50
51
|
return request.method === "POST" && !!contentType?.startsWith("application/x-www-form-urlencoded");
|
|
51
52
|
}
|
|
53
|
+
/** Google's own form post carries no attributes; an app posting JSON may add them. */
|
|
54
|
+
const oneTapBodySchema = extend(googleOneTapSchema, { attributes: attributesSchema });
|
|
55
|
+
const desktopAuthorizeSchema = object({
|
|
56
|
+
code_challenge: string().check(regex(PKCE_FORMAT)),
|
|
57
|
+
code_challenge_method: literal("S256")
|
|
58
|
+
});
|
|
59
|
+
const desktopExchangeSchema = object({
|
|
60
|
+
code: string(),
|
|
61
|
+
code_verifier: string().check(regex(PKCE_FORMAT))
|
|
62
|
+
});
|
|
52
63
|
const PATH = {
|
|
53
64
|
CSRF: "/csrf",
|
|
54
65
|
LOGOUT: "/logout",
|
|
@@ -60,15 +71,18 @@ const PATH = {
|
|
|
60
71
|
LOGIN_OAUTH2_NATIVE: "/login/oauth2/native/:provider",
|
|
61
72
|
LOGIN_OAUTH2_ONETAP: "/login/oauth2/onetap/google",
|
|
62
73
|
LOGIN_EMAIL: "/login/email",
|
|
63
|
-
LOGIN_PHONE: "/login/phone",
|
|
64
74
|
SEND_EMAIL_VERIFICATION_CODE: "/auth/sendEmailVerificationCode",
|
|
65
|
-
SEND_PHONE_VERIFICATION_CODE: "/auth/sendPhoneVerificationCode",
|
|
66
75
|
DESKTOP_AUTHORIZE: "/auth/desktop/authorize",
|
|
67
76
|
DESKTOP_EXCHANGE: "/auth/desktop/exchange",
|
|
68
77
|
CLEANUP_EXPIRED_SESSIONS: "/sessions/expired/cleanup"
|
|
69
78
|
};
|
|
70
|
-
/**
|
|
71
|
-
|
|
79
|
+
/**
|
|
80
|
+
* The login routes and session helpers. Every route is one straight line: read and validate the
|
|
81
|
+
* request, check the credential, call the host's `onAuthorized`, sign in. Failures are thrown and
|
|
82
|
+
* answered in one place per kind of route: a JSON route runs under `guard`, which maps a
|
|
83
|
+
* `RequestError`, an `AuthenticationError` or an `OAuth2Error` to its google.rpc body (see
|
|
84
|
+
* `respond.ts`); the redirect callback maps the same errors to `errorUri` parameters (`redirect`).
|
|
85
|
+
*/
|
|
72
86
|
var Auth = class {
|
|
73
87
|
timing;
|
|
74
88
|
cookieName;
|
|
@@ -76,21 +90,27 @@ var Auth = class {
|
|
|
76
90
|
kv;
|
|
77
91
|
repository;
|
|
78
92
|
turnstileSecretKey;
|
|
79
|
-
|
|
80
|
-
|
|
93
|
+
emailCodes;
|
|
94
|
+
desktopCodes;
|
|
81
95
|
oauth2Client;
|
|
82
96
|
onetapRequireNonce;
|
|
83
97
|
touchInterval;
|
|
98
|
+
sessionHeader;
|
|
84
99
|
ATTR_OAUTH2_AUTHORIZATION_REQUEST = "oauth2AuthorizationRequest";
|
|
85
100
|
constructor({ sessionRepository, kvRepository, turnstileSecretKey, verificationCode, oauth2, cookie, session, timing }) {
|
|
86
101
|
this.timing = timing ?? false;
|
|
87
102
|
this.kv = kvRepository;
|
|
88
103
|
this.repository = sessionRepository;
|
|
89
104
|
this.turnstileSecretKey = turnstileSecretKey;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
invariant(
|
|
93
|
-
invariant(
|
|
105
|
+
const maxAttempts = verificationCode?.maxAttempts ?? 5;
|
|
106
|
+
const resendCooldown = verificationCode?.resendCooldown ?? 60;
|
|
107
|
+
invariant(maxAttempts >= 1, "verificationCode.maxAttempts must be at least 1");
|
|
108
|
+
invariant(resendCooldown >= 1, "verificationCode.resendCooldown must be at least 1 second");
|
|
109
|
+
this.emailCodes = new EmailVerificationCodes(kvRepository, {
|
|
110
|
+
maxAttempts,
|
|
111
|
+
resendCooldown
|
|
112
|
+
});
|
|
113
|
+
this.desktopCodes = new DesktopCodes(kvRepository);
|
|
94
114
|
const { name, ...cookieOptions } = cookie ?? {};
|
|
95
115
|
this.cookieName = name ?? "SESSION";
|
|
96
116
|
this.cookieOptions = {
|
|
@@ -103,14 +123,76 @@ var Auth = class {
|
|
|
103
123
|
this.oauth2Client = oauth2?.client ? new OAuth2Client(oauth2.client) : null;
|
|
104
124
|
this.onetapRequireNonce = oauth2?.onetap?.requireNonce ?? true;
|
|
105
125
|
this.touchInterval = (session?.touchInterval ?? 3600) * 1e3;
|
|
126
|
+
this.sessionHeader = session?.header ?? false;
|
|
106
127
|
invariant(this.touchInterval >= 0, "session.touchInterval must not be negative");
|
|
107
128
|
}
|
|
108
129
|
getSessionId(request) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
130
|
+
const fromCookie = getCookie(request, this.cookieName);
|
|
131
|
+
if (fromCookie) return fromCookie;
|
|
132
|
+
return this.sessionHeader && request.headers.get(SESSION_HEADER) || void 0;
|
|
133
|
+
}
|
|
134
|
+
/** The session the request carries, if it exists. */
|
|
135
|
+
async loadSession(request) {
|
|
136
|
+
const sessionId = this.getSessionId(request);
|
|
137
|
+
return sessionId ? this.repository.findById(sessionId) : null;
|
|
138
|
+
}
|
|
139
|
+
/** The signed-in principal of a session, if any. */
|
|
140
|
+
principalOf(session) {
|
|
141
|
+
const name = session.getAttribute(PRINCIPAL_NAME_INDEX_NAME);
|
|
142
|
+
return name ? { name: String(name) } : null;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* A new session signed in as `principal`. With `carryOver`, what the host kept in the pre-login
|
|
146
|
+
* session moves to the new one; the authorization request (state, PKCE verifier, flow
|
|
147
|
+
* attributes) is single-use and was consumed, and a principal left there from an earlier
|
|
148
|
+
* sign-in is replaced. The new id is never the one the browser brought, which may have been
|
|
149
|
+
* planted by an attacker who started the flow (session fixation).
|
|
150
|
+
*/
|
|
151
|
+
async signIn(principal, carryOver) {
|
|
152
|
+
const session = this.repository.createSession();
|
|
153
|
+
for (const attribute of carryOver?.getAttributeNames() ?? []) {
|
|
154
|
+
if (attribute === this.ATTR_OAUTH2_AUTHORIZATION_REQUEST) continue;
|
|
155
|
+
if (attribute === "principalName") continue;
|
|
156
|
+
const value = carryOver?.getAttribute(attribute);
|
|
157
|
+
if (value !== null && value !== void 0) session.setAttribute(attribute, value);
|
|
158
|
+
}
|
|
159
|
+
session.setAttribute(PRINCIPAL_NAME_INDEX_NAME, principal.name);
|
|
160
|
+
await this.repository.save(session);
|
|
161
|
+
return session;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* `response` carrying `session`: its cookie, renewed for the whole inactive interval, and with
|
|
165
|
+
* `session.header` on, the `X-Auth-Token` header a client without a cookie jar reads.
|
|
166
|
+
*/
|
|
167
|
+
withSession(response, session, cookie) {
|
|
168
|
+
const maxAge = session.getMaxInactiveInterval();
|
|
169
|
+
setCookie(response, this.cookieName, session.getId(), {
|
|
170
|
+
...this.cookieOptions,
|
|
171
|
+
...cookie,
|
|
172
|
+
maxAge
|
|
173
|
+
});
|
|
174
|
+
if (this.sessionHeader) response.headers.set(SESSION_HEADER, session.getId());
|
|
175
|
+
return response;
|
|
176
|
+
}
|
|
177
|
+
/** Runs a JSON route: a failure it knows is its response, anything else is the host's. */
|
|
178
|
+
async guard(run) {
|
|
179
|
+
try {
|
|
180
|
+
return await run();
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const response = errorResponse(error);
|
|
183
|
+
if (response) return response;
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** The error redirect of the callback flow; `refusal`'s reason and metadata ride along. */
|
|
188
|
+
redirect(error, description, refusal) {
|
|
189
|
+
invariant(this.oauth2Client, "oauth2Client is not initialized");
|
|
190
|
+
const uri = new URL(this.oauth2Client.errorUri);
|
|
191
|
+
for (const [key, value] of Object.entries(refusal?.metadata ?? {})) if (!uri.searchParams.has(key)) uri.searchParams.set(key, value);
|
|
192
|
+
uri.searchParams.set("error", error);
|
|
193
|
+
if (description) uri.searchParams.set("error_description", description.slice(0, MAX_ERROR_DESCRIPTION_LENGTH));
|
|
194
|
+
if (refusal) uri.searchParams.set("reason", refusal.reason);
|
|
195
|
+
return Response.redirect(uri.href, 302);
|
|
114
196
|
}
|
|
115
197
|
csrf = async (_request) => {
|
|
116
198
|
const token = randomUUID();
|
|
@@ -136,24 +218,16 @@ var Auth = class {
|
|
|
136
218
|
return response;
|
|
137
219
|
};
|
|
138
220
|
logged = async (request, onLogged) => {
|
|
139
|
-
const
|
|
140
|
-
if (!sessionId) return Response.json({ data: false });
|
|
141
|
-
const session = await this.repository.findById(sessionId);
|
|
221
|
+
const session = await this.loadSession(request);
|
|
142
222
|
if (!session) return Response.json({ data: false });
|
|
143
|
-
const name = session.getAttribute(PRINCIPAL_NAME_INDEX_NAME);
|
|
144
223
|
session.setLastAccessedTime(Date.now());
|
|
145
224
|
await this.repository.save(session);
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
setCookie(response, this.cookieName, session.getId(), {
|
|
150
|
-
...this.cookieOptions,
|
|
151
|
-
maxAge
|
|
152
|
-
});
|
|
153
|
-
return response;
|
|
225
|
+
const principal = this.principalOf(session);
|
|
226
|
+
if (principal) await onLogged?.(principal);
|
|
227
|
+
return this.withSession(Response.json({ data: principal !== null }), session);
|
|
154
228
|
};
|
|
155
229
|
createPkceParameters() {
|
|
156
|
-
const code_verifier =
|
|
230
|
+
const code_verifier = randomBytes(32).toString("base64url");
|
|
157
231
|
return {
|
|
158
232
|
code_verifier,
|
|
159
233
|
code_challenge: createHash("sha256").update(code_verifier).digest().toString("base64url"),
|
|
@@ -166,34 +240,29 @@ var Auth = class {
|
|
|
166
240
|
getOauth2NonceKey(nonce) {
|
|
167
241
|
return `oauth2:nonce:${nonce}`;
|
|
168
242
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
getPhoneVerificationCodeKey(phone) {
|
|
179
|
-
return `phone:verification:${phone}`;
|
|
180
|
-
}
|
|
181
|
-
getDesktopCodeKey(code) {
|
|
182
|
-
return `desktop:code:${code}`;
|
|
243
|
+
/**
|
|
244
|
+
* Spends a nonce `oauth2Nonce` issued for `provider`: `NONCE_MISMATCH` for one that was not,
|
|
245
|
+
* or was used already. An id token is replayable until it expires; the nonce it carries makes
|
|
246
|
+
* it sign in once.
|
|
247
|
+
*/
|
|
248
|
+
async consumeNonce(nonce, provider) {
|
|
249
|
+
const key = this.getOauth2NonceKey(nonce);
|
|
250
|
+
if (await this.kv.getItem(key) !== provider) throw new AuthenticationError("NONCE_MISMATCH");
|
|
251
|
+
await this.kv.removeItem(key);
|
|
183
252
|
}
|
|
184
253
|
oauth2State = async (request) => {
|
|
185
254
|
const { provider } = param(request, PATH.OAUTH2_STATE);
|
|
186
255
|
const state = randomUUID();
|
|
187
|
-
await this.kv.setItem(this.getOauth2StateKey(state), provider,
|
|
256
|
+
await this.kv.setItem(this.getOauth2StateKey(state), provider, OAUTH2_PARAMETER_TTL);
|
|
188
257
|
return Response.json({ state });
|
|
189
258
|
};
|
|
190
259
|
oauth2Nonce = async (request) => {
|
|
191
260
|
const { provider } = param(request, PATH.OAUTH2_NONCE);
|
|
192
261
|
const nonce = randomUUID();
|
|
193
|
-
await this.kv.setItem(this.getOauth2NonceKey(nonce), provider,
|
|
262
|
+
await this.kv.setItem(this.getOauth2NonceKey(nonce), provider, OAUTH2_PARAMETER_TTL);
|
|
194
263
|
return Response.json({ nonce });
|
|
195
264
|
};
|
|
196
|
-
oauth2Authorization =
|
|
265
|
+
oauth2Authorization = (request, options) => this.guard(async () => {
|
|
197
266
|
invariant(this.oauth2Client, "oauth2Client is not initialized");
|
|
198
267
|
const attributes = authorizationAttributes(options?.attributes);
|
|
199
268
|
const { mark, setTiming } = timing({ enabled: this.timing });
|
|
@@ -205,8 +274,11 @@ var Auth = class {
|
|
|
205
274
|
state,
|
|
206
275
|
pkce
|
|
207
276
|
});
|
|
208
|
-
|
|
209
|
-
|
|
277
|
+
let session = await this.loadSession(request);
|
|
278
|
+
if (!session) {
|
|
279
|
+
session = this.repository.createSession();
|
|
280
|
+
session.setMaxInactiveInterval(OAUTH2_PARAMETER_TTL);
|
|
281
|
+
}
|
|
210
282
|
mark("fetch_session");
|
|
211
283
|
const authorizationRequest = {
|
|
212
284
|
state,
|
|
@@ -215,56 +287,38 @@ var Auth = class {
|
|
|
215
287
|
additionalParameters: pkce,
|
|
216
288
|
...attributes ? { attributes } : {}
|
|
217
289
|
};
|
|
218
|
-
|
|
219
|
-
session.setAttribute(this.ATTR_OAUTH2_AUTHORIZATION_REQUEST, value);
|
|
290
|
+
session.setAttribute(this.ATTR_OAUTH2_AUTHORIZATION_REQUEST, JSON.stringify(authorizationRequest));
|
|
220
291
|
await this.repository.save(session);
|
|
221
292
|
mark("save_session");
|
|
222
293
|
const response = new Response(null, {
|
|
223
294
|
status: 302,
|
|
224
295
|
headers: { location: uri.href }
|
|
225
296
|
});
|
|
226
|
-
const maxAge = session.getMaxInactiveInterval();
|
|
227
|
-
setCookie(response, this.cookieName, session.getId(), {
|
|
228
|
-
...this.cookieOptions,
|
|
229
|
-
maxAge
|
|
230
|
-
});
|
|
231
297
|
setTiming(response);
|
|
232
|
-
return response;
|
|
233
|
-
};
|
|
234
|
-
redirect = (error, description) => {
|
|
235
|
-
invariant(this.oauth2Client, "oauth2Client is not initialized");
|
|
236
|
-
const uri = new URL(this.oauth2Client.errorUri);
|
|
237
|
-
uri.searchParams.set("error", error);
|
|
238
|
-
if (description) uri.searchParams.set("error_description", description.slice(0, MAX_ERROR_DESCRIPTION_LENGTH));
|
|
239
|
-
return Response.redirect(uri.href, 302);
|
|
240
|
-
};
|
|
298
|
+
return this.withSession(response, session);
|
|
299
|
+
});
|
|
241
300
|
loginOAuth2Code = async (request, handler) => {
|
|
242
301
|
invariant(this.oauth2Client, "oauth2Client is not initialized");
|
|
243
302
|
const { mark, setTiming } = timing({ enabled: this.timing });
|
|
244
|
-
const
|
|
245
|
-
const session = sessionId ? await this.repository.findById(sessionId) : null;
|
|
303
|
+
const session = await this.loadSession(request);
|
|
246
304
|
if (!session) return this.redirect("invalid_request", "session not found");
|
|
247
305
|
mark("load_session");
|
|
248
306
|
const json = session.getAttribute(this.ATTR_OAUTH2_AUTHORIZATION_REQUEST);
|
|
249
307
|
if (!json) return this.redirect("invalid_request", "authorization request not found");
|
|
250
308
|
const cached = JSON.parse(json);
|
|
251
309
|
const { provider } = param(request, PATH.LOGIN_OAUTH2_CODE);
|
|
252
|
-
|
|
253
|
-
if (isFormPost(request)) {
|
|
254
|
-
const params = new URLSearchParams(await request.text());
|
|
255
|
-
data = Object.fromEntries(params.entries());
|
|
256
|
-
} else data = query(request);
|
|
310
|
+
const data = isFormPost(request) ? Object.fromEntries(new URLSearchParams(await request.text())) : query(request);
|
|
257
311
|
const parsed = oauth2RedirectQuerySchema.safeParse(data);
|
|
258
312
|
if (!parsed.success) return this.redirect("invalid_request", "invalid redirect query");
|
|
259
313
|
if (parsed.data.error) {
|
|
260
314
|
if (parsed.data.state && parsed.data.state !== cached.state) return this.redirect("invalid_request", "redirect state mismatch");
|
|
261
315
|
return this.redirect(safeErrorType(parsed.data.error, "server_error"), parsed.data.error_description);
|
|
262
316
|
}
|
|
263
|
-
|
|
264
|
-
if (
|
|
317
|
+
const { code, state, user } = parsed.data;
|
|
318
|
+
if (!code || !state) return this.redirect("invalid_request", "invalid redirect query");
|
|
319
|
+
if (state !== cached.state) return this.redirect("invalid_request", "redirect state mismatch");
|
|
265
320
|
if (provider !== cached.provider) return this.redirect("invalid_request", "redirect provider mismatch");
|
|
266
321
|
mark("validate_session");
|
|
267
|
-
const { code, user } = parsed.data;
|
|
268
322
|
let signedIn;
|
|
269
323
|
try {
|
|
270
324
|
const { clientId, token } = await this.oauth2Client.exchangeAuthorizationCode({
|
|
@@ -287,8 +341,7 @@ var Auth = class {
|
|
|
287
341
|
attributes: cached.attributes ?? {}
|
|
288
342
|
});
|
|
289
343
|
mark("save_principal");
|
|
290
|
-
signedIn = this.
|
|
291
|
-
await this.repository.save(signedIn);
|
|
344
|
+
signedIn = await this.signIn(principal, session);
|
|
292
345
|
await this.repository.deleteById(session.getId());
|
|
293
346
|
mark("update_session");
|
|
294
347
|
} catch (error) {
|
|
@@ -297,6 +350,10 @@ var Auth = class {
|
|
|
297
350
|
const type = CALLBACK_ERROR_TYPES.has(error.error) ? error.error : "server_error";
|
|
298
351
|
return this.redirect(type);
|
|
299
352
|
}
|
|
353
|
+
if (error instanceof AuthenticationError) {
|
|
354
|
+
console.warn("OAuth2 sign-in refused", error.reason, error.metadata);
|
|
355
|
+
return this.redirect("access_denied", void 0, error);
|
|
356
|
+
}
|
|
300
357
|
console.error("OAuth2 callback failed", error);
|
|
301
358
|
return this.redirect("server_error");
|
|
302
359
|
}
|
|
@@ -307,328 +364,157 @@ var Auth = class {
|
|
|
307
364
|
status: 302,
|
|
308
365
|
headers: { location: uri.href }
|
|
309
366
|
});
|
|
310
|
-
const maxAge = signedIn.getMaxInactiveInterval();
|
|
311
|
-
setCookie(response, this.cookieName, signedIn.getId(), {
|
|
312
|
-
...this.cookieOptions,
|
|
313
|
-
maxAge
|
|
314
|
-
});
|
|
315
367
|
setTiming(response);
|
|
316
|
-
return response;
|
|
368
|
+
return this.withSession(response, signedIn);
|
|
317
369
|
};
|
|
318
|
-
/**
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
for (const attribute of previous.getAttributeNames()) {
|
|
326
|
-
if (attribute === this.ATTR_OAUTH2_AUTHORIZATION_REQUEST) continue;
|
|
327
|
-
if (attribute === "org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME") continue;
|
|
328
|
-
const value = previous.getAttribute(attribute);
|
|
329
|
-
if (value !== null) session.setAttribute(attribute, value);
|
|
330
|
-
}
|
|
331
|
-
session.setAttribute(PRINCIPAL_NAME_INDEX_NAME, name);
|
|
332
|
-
return session;
|
|
370
|
+
/** The shared tail of the JSON login routes: the host's handler, then a signed-in session. */
|
|
371
|
+
async authorize(request, handler, authorization, setTiming) {
|
|
372
|
+
const principal = await handler(request, authorization);
|
|
373
|
+
const session = await this.signIn(principal);
|
|
374
|
+
const response = Response.json(principal);
|
|
375
|
+
setTiming(response);
|
|
376
|
+
return this.withSession(response, session);
|
|
333
377
|
}
|
|
334
|
-
loginOAuth2Native =
|
|
378
|
+
loginOAuth2Native = (request, handler) => this.guard(async () => {
|
|
335
379
|
invariant(this.oauth2Client, "oauth2Client is not initialized");
|
|
336
380
|
const { mark, setTiming } = timing({ enabled: this.timing });
|
|
337
381
|
const { provider } = param(request, PATH.LOGIN_OAUTH2_NATIVE);
|
|
338
|
-
const body = await request
|
|
382
|
+
const body = await readJson(request);
|
|
339
383
|
const attributes = bodyAttributes(body);
|
|
340
|
-
if (!attributes) return Response.json(INVALID_ATTRIBUTES, { status: 400 });
|
|
341
384
|
const { attributes: _, ...credentials } = body;
|
|
342
385
|
const { code_verifier } = credentials;
|
|
343
|
-
if (code_verifier !== void 0 && (typeof code_verifier !== "string" || !CODE_VERIFIER_FORMAT.test(code_verifier)))
|
|
386
|
+
if (code_verifier !== void 0 && (typeof code_verifier !== "string" || !CODE_VERIFIER_FORMAT.test(code_verifier))) throw invalidArgument("INVALID_CODE_VERIFIER", "code_verifier must be 43-128 characters from [A-Za-z0-9-._~]");
|
|
344
387
|
const { clientId, userInfo, token } = await this.oauth2Client.loginOAuth2Native({
|
|
345
388
|
provider,
|
|
346
389
|
credentials
|
|
347
390
|
});
|
|
348
391
|
mark("login_oauth2_native");
|
|
349
392
|
const { nonce } = userInfo.data;
|
|
350
|
-
if (nonce) {
|
|
351
|
-
|
|
352
|
-
error: "invalid_request",
|
|
353
|
-
error_description: "nonce mismatch"
|
|
354
|
-
}, { status: 400 });
|
|
355
|
-
await this.kv.removeItem(this.getOauth2NonceKey(nonce));
|
|
393
|
+
if (typeof nonce === "string" && nonce) {
|
|
394
|
+
await this.consumeNonce(nonce, provider);
|
|
356
395
|
mark("check_nonce");
|
|
357
396
|
}
|
|
358
|
-
|
|
397
|
+
return this.authorize(request, handler, {
|
|
359
398
|
provider,
|
|
360
399
|
clientId,
|
|
361
400
|
userInfo,
|
|
362
401
|
token,
|
|
363
402
|
attributes
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
session.setAttribute(PRINCIPAL_NAME_INDEX_NAME, principal.name);
|
|
368
|
-
await this.repository.save(session);
|
|
369
|
-
const response = new Response(JSON.stringify(principal), {
|
|
370
|
-
status: 200,
|
|
371
|
-
headers: { "Content-Type": "application/json" }
|
|
372
|
-
});
|
|
373
|
-
const maxAge = session.getMaxInactiveInterval();
|
|
374
|
-
setCookie(response, this.cookieName, session.getId(), {
|
|
375
|
-
...this.cookieOptions,
|
|
376
|
-
maxAge
|
|
377
|
-
});
|
|
378
|
-
setTiming(response);
|
|
379
|
-
return response;
|
|
380
|
-
};
|
|
381
|
-
loginOAuth2Onetap = async (request, handler, provider = "google") => {
|
|
403
|
+
}, setTiming);
|
|
404
|
+
});
|
|
405
|
+
loginOAuth2Onetap = (request, handler, provider = "google") => this.guard(async () => {
|
|
382
406
|
invariant(this.oauth2Client, "oauth2Client is not initialized");
|
|
383
407
|
const { mark, setTiming } = timing({ enabled: this.timing });
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
} else if (request.headers.get("Content-Type")?.includes("application/json")) {
|
|
390
|
-
const json = await request.json();
|
|
391
|
-
attributes = bodyAttributes(json);
|
|
392
|
-
body = json;
|
|
393
|
-
} else return Response.json({
|
|
394
|
-
error: "invalid_request",
|
|
395
|
-
error_description: "invalid content type"
|
|
396
|
-
}, { status: 400 });
|
|
397
|
-
if (!attributes) return Response.json(INVALID_ATTRIBUTES, { status: 400 });
|
|
398
|
-
const parsed = googleOneTapSchema.parse(body);
|
|
399
|
-
let principal;
|
|
408
|
+
const body = (request.headers.get("content-type") ?? "").includes("application/json") ? await readJson(request) : await readForm(request);
|
|
409
|
+
const { attributes = {}, ...parsed } = parse(oneTapBodySchema, body);
|
|
410
|
+
let clientId;
|
|
411
|
+
let token;
|
|
412
|
+
let userInfo;
|
|
400
413
|
if (parsed.credential) {
|
|
401
|
-
|
|
414
|
+
({clientId, token, userInfo} = await this.oauth2Client.getTokenInfo({
|
|
402
415
|
provider,
|
|
403
416
|
credential: parsed.credential
|
|
404
|
-
});
|
|
417
|
+
}));
|
|
405
418
|
mark("verify_credential");
|
|
406
419
|
const { nonce } = userInfo.data;
|
|
407
420
|
if (typeof nonce === "string" && nonce) {
|
|
408
|
-
|
|
409
|
-
error: "invalid_request",
|
|
410
|
-
error_description: "nonce mismatch"
|
|
411
|
-
}, { status: 401 });
|
|
412
|
-
await this.kv.removeItem(this.getOauth2NonceKey(nonce));
|
|
421
|
+
await this.consumeNonce(nonce, provider);
|
|
413
422
|
mark("check_nonce");
|
|
414
|
-
} else if (this.onetapRequireNonce)
|
|
415
|
-
error: "invalid_request",
|
|
416
|
-
error_description: "nonce missing"
|
|
417
|
-
}, { status: 401 });
|
|
418
|
-
principal = await handler(request, {
|
|
419
|
-
provider,
|
|
420
|
-
clientId,
|
|
421
|
-
userInfo,
|
|
422
|
-
token,
|
|
423
|
-
attributes
|
|
424
|
-
});
|
|
423
|
+
} else if (this.onetapRequireNonce) throw new AuthenticationError("NONCE_MISSING");
|
|
425
424
|
} else if (parsed.code) {
|
|
426
|
-
|
|
425
|
+
({clientId, token} = await this.oauth2Client.exchangeAuthorizationCode({
|
|
427
426
|
provider,
|
|
428
427
|
code: parsed.code
|
|
429
|
-
});
|
|
430
|
-
|
|
428
|
+
}));
|
|
429
|
+
userInfo = await this.oauth2Client.getUserInfo({
|
|
431
430
|
provider,
|
|
432
431
|
clientId,
|
|
433
|
-
|
|
434
|
-
provider,
|
|
435
|
-
clientId,
|
|
436
|
-
token
|
|
437
|
-
}),
|
|
438
|
-
token,
|
|
439
|
-
attributes
|
|
432
|
+
token
|
|
440
433
|
});
|
|
441
|
-
} else throw
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
setCookie(response, this.cookieName, session.getId(), {
|
|
452
|
-
...this.cookieOptions,
|
|
453
|
-
maxAge
|
|
454
|
-
});
|
|
455
|
-
setTiming(response);
|
|
456
|
-
return response;
|
|
457
|
-
};
|
|
458
|
-
sendEmailVerificationCode = async (request, sender) => {
|
|
434
|
+
} else throw invalidArgument("INVALID_ARGUMENT", "credential or code is required");
|
|
435
|
+
return this.authorize(request, handler, {
|
|
436
|
+
provider,
|
|
437
|
+
clientId,
|
|
438
|
+
userInfo,
|
|
439
|
+
token,
|
|
440
|
+
attributes
|
|
441
|
+
}, setTiming);
|
|
442
|
+
});
|
|
443
|
+
sendEmailVerificationCode = (request, sender) => this.guard(async () => {
|
|
459
444
|
invariant(this.turnstileSecretKey, "turnstileSecretKey is not configured");
|
|
460
|
-
const {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
await this.kv.removeItem(this.getEmailVerificationAttemptsKey(email));
|
|
477
|
-
await this.kv.setItem(this.getEmailVerificationCodeKey(email), verificationCode, EMAIL_VERIFICATION_CODE_TTL);
|
|
478
|
-
return Response.json({});
|
|
479
|
-
} catch (e) {
|
|
480
|
-
console.error("Failed to send email verification code", e);
|
|
481
|
-
await this.kv.removeItem(cooldownKey);
|
|
482
|
-
return Response.json(invalidArgument("EMAIL_DELIVERY_FAILED"), { status: 400 });
|
|
483
|
-
}
|
|
484
|
-
};
|
|
485
|
-
loginEmail = async (request, handler) => {
|
|
486
|
-
const { data, error } = valid(loginEmailSchema, await request.json());
|
|
487
|
-
if (error) return Response.json(error, { status: error.error.code });
|
|
488
|
-
const { email, verificationCode, attributes } = data;
|
|
489
|
-
const key = this.getEmailVerificationCodeKey(email);
|
|
490
|
-
const attemptsKey = this.getEmailVerificationAttemptsKey(email);
|
|
491
|
-
const stored = await this.kv.getItem(key);
|
|
492
|
-
if (typeof stored !== "string") return Response.json(invalidArgument("INVALID_VERIFICATION_CODE"), { status: 400 });
|
|
493
|
-
if (await this.kv.increment(attemptsKey, EMAIL_VERIFICATION_CODE_TTL) > this.maxVerificationAttempts) {
|
|
494
|
-
await this.kv.removeItem(key);
|
|
495
|
-
const error = invalidArgument("VERIFICATION_CODE_ATTEMPTS_EXCEEDED");
|
|
496
|
-
return Response.json(error, { status: error.error.code });
|
|
497
|
-
}
|
|
498
|
-
if (stored.length !== verificationCode.length || !timingSafeEqual(Buffer.from(stored), Buffer.from(verificationCode))) return Response.json(invalidArgument("INVALID_VERIFICATION_CODE"), { status: 400 });
|
|
499
|
-
await this.kv.removeItem(key);
|
|
500
|
-
await this.kv.removeItem(attemptsKey);
|
|
501
|
-
const principal = await handler(request, {
|
|
445
|
+
const { email, turnstileToken } = parse(sendEmailVerificationCodeSchema, await readJson(request));
|
|
446
|
+
await verifyTurnstileToken(this.turnstileSecretKey, turnstileToken);
|
|
447
|
+
await this.emailCodes.send(email, sender);
|
|
448
|
+
return Response.json({});
|
|
449
|
+
});
|
|
450
|
+
verifyEmailCode = (email, verificationCode) => this.emailCodes.verify(email, verificationCode);
|
|
451
|
+
loginEmail = (request, handler) => this.guard(async () => {
|
|
452
|
+
const { setTiming } = timing({ enabled: this.timing });
|
|
453
|
+
const { email, verificationCode, attributes = {} } = parse(loginEmailSchema, await readJson(request));
|
|
454
|
+
await this.emailCodes.verify(email, verificationCode);
|
|
455
|
+
const claims = {
|
|
456
|
+
sub: email,
|
|
457
|
+
email,
|
|
458
|
+
email_verified: true
|
|
459
|
+
};
|
|
460
|
+
return this.authorize(request, handler, {
|
|
502
461
|
provider: "EMAIL",
|
|
503
462
|
userInfo: {
|
|
504
|
-
claims
|
|
505
|
-
sub: email,
|
|
506
|
-
email,
|
|
507
|
-
email_verified: true
|
|
508
|
-
},
|
|
463
|
+
claims,
|
|
509
464
|
data: {}
|
|
510
465
|
},
|
|
511
|
-
attributes
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
session.setAttribute(PRINCIPAL_NAME_INDEX_NAME, principal.name);
|
|
515
|
-
await this.repository.save(session);
|
|
516
|
-
const response = new Response(JSON.stringify(principal), {
|
|
517
|
-
status: 200,
|
|
518
|
-
headers: { "Content-Type": "application/json" }
|
|
519
|
-
});
|
|
520
|
-
const maxAge = session.getMaxInactiveInterval();
|
|
521
|
-
setCookie(response, this.cookieName, session.getId(), {
|
|
522
|
-
...this.cookieOptions,
|
|
523
|
-
maxAge
|
|
524
|
-
});
|
|
525
|
-
return response;
|
|
526
|
-
};
|
|
466
|
+
attributes
|
|
467
|
+
}, setTiming);
|
|
468
|
+
});
|
|
527
469
|
/**
|
|
528
|
-
* Step 1 of the desktop / native app sign-in handshake. The caller already
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
* desktop client via a loopback redirect.
|
|
470
|
+
* Step 1 of the desktop / native app sign-in handshake. The caller already completed normal
|
|
471
|
+
* sign-in on the web (so a cookie session exists); this issues a 5-minute single-use code that
|
|
472
|
+
* the web page hands off to the desktop client via a loopback redirect.
|
|
532
473
|
*
|
|
533
|
-
* PKCE (RFC 7636, S256 only): the desktop generates a random `code_verifier`
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
* the
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
* capture on loopback) from exchanging it.
|
|
474
|
+
* PKCE (RFC 7636, S256 only): the desktop generates a random `code_verifier` before opening the
|
|
475
|
+
* browser, derives `code_challenge = base64url(sha256(verifier))`, and threads the challenge
|
|
476
|
+
* through the URL → web page → here. The challenge is pinned to the code; `desktopExchange`
|
|
477
|
+
* enforces that the caller knows the matching verifier. This blocks an attacker who only
|
|
478
|
+
* intercepts the auth code (browser history, malicious extension, packet capture on loopback)
|
|
479
|
+
* from exchanging it.
|
|
540
480
|
*
|
|
541
|
-
* Requires an authenticated cookie session
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
* upstream sign-in flow already passed CAPTCHA / OAuth / verification.
|
|
481
|
+
* Requires an authenticated cookie session: anonymous callers get 401. Throttling / abuse
|
|
482
|
+
* protection on this endpoint is the API layer's job (rate-limit per principal); the SDK does
|
|
483
|
+
* not gate it because the upstream sign-in flow already passed CAPTCHA / OAuth / verification.
|
|
545
484
|
*/
|
|
546
|
-
desktopAuthorize = async (
|
|
485
|
+
desktopAuthorize = (request) => this.guard(async () => {
|
|
547
486
|
const principal = await this.getPrincipal(request);
|
|
548
|
-
if (!principal)
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
message: "sign in first",
|
|
552
|
-
details: []
|
|
553
|
-
} }, { status: 401 });
|
|
554
|
-
let body;
|
|
555
|
-
try {
|
|
556
|
-
body = await request.json();
|
|
557
|
-
} catch {
|
|
558
|
-
return Response.json(invalidArgument("INVALID_ARGUMENT"), { status: 400 });
|
|
559
|
-
}
|
|
560
|
-
const codeChallenge = typeof body.code_challenge === "string" ? body.code_challenge : null;
|
|
561
|
-
if (!codeChallenge || body.code_challenge_method !== "S256" || !PKCE_FORMAT.test(codeChallenge)) return Response.json(invalidArgument("INVALID_ARGUMENT"), { status: 400 });
|
|
562
|
-
const code = randomUUID();
|
|
563
|
-
const record = {
|
|
564
|
-
name: principal.name,
|
|
565
|
-
cc: codeChallenge
|
|
566
|
-
};
|
|
567
|
-
await this.kv.setItem(this.getDesktopCodeKey(code), JSON.stringify(record), 300);
|
|
487
|
+
if (!principal) throw unauthenticated("Sign in first");
|
|
488
|
+
const { code_challenge } = parse(desktopAuthorizeSchema, await readJson(request));
|
|
489
|
+
const code = await this.desktopCodes.issue(principal, code_challenge);
|
|
568
490
|
return Response.json({ code });
|
|
569
|
-
};
|
|
491
|
+
});
|
|
570
492
|
/**
|
|
571
|
-
* Step 2 of the desktop / native app sign-in handshake. The desktop
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
* either side doesn't cascade to the other.
|
|
493
|
+
* Step 2 of the desktop / native app sign-in handshake. The desktop client hands back the auth
|
|
494
|
+
* code it received via the loopback callback, and we mint a fresh session for it, independent
|
|
495
|
+
* of the web cookie session that originally authorized the code, so logging out on either side
|
|
496
|
+
* does not cascade to the other.
|
|
576
497
|
*
|
|
577
|
-
* Cookie attributes:
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
* backend this combination is rejected by Chromium — see the desktop
|
|
583
|
-
* sign-in PRD for workarounds.
|
|
498
|
+
* Cookie attributes: `SameSite=None; Secure` so the cookie survives cross-site fetches from the
|
|
499
|
+
* desktop renderer's `file://` / `app://` origin. The configured `cookieOptions` provide
|
|
500
|
+
* everything else (`domain`, `path`, `httpOnly`); the override is targeted at the
|
|
501
|
+
* SameSite/Secure pair only. In a non-HTTPS dev backend this combination is rejected by
|
|
502
|
+
* Chromium, see the desktop sign-in PRD for workarounds.
|
|
584
503
|
*
|
|
585
|
-
* No CSRF check needed
|
|
586
|
-
*
|
|
504
|
+
* No CSRF check needed: the request carries no cookie, the auth code itself is the credential,
|
|
505
|
+
* and the code's single-use removal blocks replay.
|
|
587
506
|
*/
|
|
588
|
-
desktopExchange = async (
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
} catch {
|
|
593
|
-
return Response.json(invalidArgument("INVALID_ARGUMENT"), { status: 400 });
|
|
594
|
-
}
|
|
595
|
-
const code = typeof body.code === "string" ? body.code : null;
|
|
596
|
-
const verifier = typeof body.code_verifier === "string" ? body.code_verifier : null;
|
|
597
|
-
if (!code || !verifier || !PKCE_FORMAT.test(verifier)) return Response.json(invalidArgument("INVALID_ARGUMENT"), { status: 400 });
|
|
598
|
-
const key = this.getDesktopCodeKey(code);
|
|
599
|
-
const raw = await this.kv.getItem(key);
|
|
600
|
-
if (typeof raw !== "string") return Response.json(invalidArgument("INVALID_DESKTOP_CODE"), { status: 400 });
|
|
601
|
-
let record;
|
|
602
|
-
try {
|
|
603
|
-
record = JSON.parse(raw);
|
|
604
|
-
} catch {
|
|
605
|
-
await this.kv.removeItem(key);
|
|
606
|
-
return Response.json(invalidArgument("INVALID_DESKTOP_CODE"), { status: 400 });
|
|
607
|
-
}
|
|
608
|
-
const computed = createHash("sha256").update(verifier).digest();
|
|
609
|
-
const stored = Buffer.from(record.cc, "base64url");
|
|
610
|
-
if (stored.length !== computed.length || !timingSafeEqual(computed, stored)) return Response.json(invalidArgument("INVALID_DESKTOP_CODE"), { status: 400 });
|
|
611
|
-
await this.kv.removeItem(key);
|
|
612
|
-
const session = this.repository.createSession();
|
|
613
|
-
session.setAttribute(PRINCIPAL_NAME_INDEX_NAME, record.name);
|
|
507
|
+
desktopExchange = (request) => this.guard(async () => {
|
|
508
|
+
const { code, code_verifier } = parse(desktopExchangeSchema, await readJson(request));
|
|
509
|
+
const principal = await this.desktopCodes.redeem(code, code_verifier);
|
|
510
|
+
const session = await this.signIn(principal);
|
|
614
511
|
session.setAttribute("client_type", "desktop");
|
|
615
512
|
await this.repository.save(session);
|
|
616
|
-
|
|
617
|
-
const maxAge = session.getMaxInactiveInterval();
|
|
618
|
-
setCookie(response, this.cookieName, session.getId(), {
|
|
619
|
-
...this.cookieOptions,
|
|
513
|
+
return this.withSession(Response.json({ ok: true }), session, {
|
|
620
514
|
sameSite: "none",
|
|
621
|
-
secure: true
|
|
622
|
-
maxAge
|
|
515
|
+
secure: true
|
|
623
516
|
});
|
|
624
|
-
|
|
625
|
-
};
|
|
626
|
-
sendPhoneVerificationCode = async (_request, _sender) => {
|
|
627
|
-
return Response.json({ error: "not_implemented" }, { status: 501 });
|
|
628
|
-
};
|
|
629
|
-
loginPhone = async (_request, _handler) => {
|
|
630
|
-
return Response.json({ error: "not_implemented" }, { status: 501 });
|
|
631
|
-
};
|
|
517
|
+
});
|
|
632
518
|
kick = async (principal) => {
|
|
633
519
|
const sessions = await this.repository.findByPrincipalName(principal.name);
|
|
634
520
|
for (const sessionId of sessions.keys()) await this.repository.deleteById(sessionId);
|
|
@@ -646,47 +532,26 @@ var Auth = class {
|
|
|
646
532
|
session.setLastAccessedTime(now);
|
|
647
533
|
await this.repository.save(session);
|
|
648
534
|
}
|
|
649
|
-
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
if (!session) return false;
|
|
654
|
-
if (!session.getAttribute("org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME")) return false;
|
|
535
|
+
getPrincipal = async (request) => {
|
|
536
|
+
const session = await this.loadSession(request);
|
|
537
|
+
const principal = session && this.principalOf(session);
|
|
538
|
+
if (!principal) return null;
|
|
655
539
|
await this.touch(session);
|
|
656
|
-
return
|
|
540
|
+
return principal;
|
|
541
|
+
};
|
|
542
|
+
isAuthenticated = async (request) => {
|
|
543
|
+
return await this.getPrincipal(request) !== null;
|
|
657
544
|
};
|
|
658
545
|
getSession = async (request, create) => {
|
|
659
|
-
const
|
|
660
|
-
if (!
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
return session;
|
|
665
|
-
} else return null;
|
|
666
|
-
}
|
|
667
|
-
const session = await this.repository.findById(sessionId);
|
|
668
|
-
if (!session) {
|
|
669
|
-
if (create) {
|
|
670
|
-
const session = this.repository.createSession();
|
|
671
|
-
await this.repository.save(session);
|
|
672
|
-
return session;
|
|
673
|
-
} else return null;
|
|
674
|
-
}
|
|
675
|
-
return session;
|
|
546
|
+
const session = await this.loadSession(request);
|
|
547
|
+
if (session || !create) return session;
|
|
548
|
+
const created = this.repository.createSession();
|
|
549
|
+
await this.repository.save(created);
|
|
550
|
+
return created;
|
|
676
551
|
};
|
|
677
552
|
deleteSession = async (sessionId) => {
|
|
678
553
|
await this.repository.deleteById(sessionId);
|
|
679
554
|
};
|
|
680
|
-
getPrincipal = async (request) => {
|
|
681
|
-
const sessionId = this.getSessionId(request);
|
|
682
|
-
if (!sessionId) return null;
|
|
683
|
-
const session = await this.repository.findById(sessionId);
|
|
684
|
-
if (!session) return null;
|
|
685
|
-
const name = session.getAttribute(PRINCIPAL_NAME_INDEX_NAME);
|
|
686
|
-
if (!name) return null;
|
|
687
|
-
await this.touch(session);
|
|
688
|
-
return { name: String(name) };
|
|
689
|
-
};
|
|
690
555
|
listSessions = async (principal) => {
|
|
691
556
|
const sessions = await this.repository.findByPrincipalName(principal.name);
|
|
692
557
|
return Array.from(sessions.values());
|