@shware/security 7.1.0 → 8.0.0
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 +108 -30
- package/dist/auth/attributes.cjs +5 -5
- package/dist/auth/attributes.cjs.map +1 -1
- package/dist/auth/attributes.d.cts +3 -5
- package/dist/auth/attributes.d.cts.map +1 -1
- package/dist/auth/attributes.d.mts +3 -5
- package/dist/auth/attributes.d.mts.map +1 -1
- package/dist/auth/attributes.mjs +5 -5
- 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 +245 -385
- package/dist/auth/index.cjs.map +1 -1
- package/dist/auth/index.d.cts +66 -57
- package/dist/auth/index.d.cts.map +1 -1
- package/dist/auth/index.d.mts +66 -57
- package/dist/auth/index.d.mts.map +1 -1
- package/dist/auth/index.mjs +248 -388
- 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 -13
- package/dist/auth/types.d.cts.map +1 -1
- package/dist/auth/types.d.mts +27 -13
- package/dist/auth/types.d.mts.map +1 -1
- package/dist/core/index.cjs +1 -2
- package/dist/core/index.d.cts +0 -2
- package/dist/core/index.d.cts.map +1 -1
- package/dist/core/index.d.mts +0 -2
- package/dist/core/index.d.mts.map +1 -1
- package/dist/core/index.mjs +1 -2
- package/dist/email/schema.cjs +1 -2
- package/dist/email/schema.d.cts +0 -2
- package/dist/email/schema.d.cts.map +1 -1
- package/dist/email/schema.d.mts +0 -2
- package/dist/email/schema.d.mts.map +1 -1
- package/dist/email/schema.mjs +1 -2
- 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/client.cjs +1 -2
- package/dist/oauth2/client.d.cts +0 -2
- package/dist/oauth2/client.d.cts.map +1 -1
- package/dist/oauth2/client.d.mts +0 -2
- package/dist/oauth2/client.d.mts.map +1 -1
- package/dist/oauth2/client.mjs +1 -2
- package/dist/oauth2/error.cjs +1 -9
- package/dist/oauth2/error.cjs.map +1 -1
- package/dist/oauth2/error.d.cts +0 -5
- package/dist/oauth2/error.d.cts.map +1 -1
- package/dist/oauth2/error.d.mts +0 -5
- package/dist/oauth2/error.d.mts.map +1 -1
- package/dist/oauth2/error.mjs +2 -9
- package/dist/oauth2/error.mjs.map +1 -1
- package/dist/oauth2/name.cjs +1 -2
- package/dist/oauth2/name.d.cts +0 -2
- package/dist/oauth2/name.d.cts.map +1 -1
- package/dist/oauth2/name.d.mts +0 -2
- package/dist/oauth2/name.d.mts.map +1 -1
- package/dist/oauth2/name.mjs +1 -2
- package/dist/oauth2/provider/apple-client-secret.cjs +1 -2
- package/dist/oauth2/provider/apple-client-secret.d.cts +0 -2
- package/dist/oauth2/provider/apple-client-secret.d.cts.map +1 -1
- package/dist/oauth2/provider/apple-client-secret.d.mts +0 -2
- package/dist/oauth2/provider/apple-client-secret.d.mts.map +1 -1
- package/dist/oauth2/provider/apple-client-secret.mjs +1 -2
- package/dist/oauth2/provider/apple.cjs +1 -2
- package/dist/oauth2/provider/apple.d.cts +0 -2
- package/dist/oauth2/provider/apple.d.cts.map +1 -1
- package/dist/oauth2/provider/apple.d.mts +0 -2
- package/dist/oauth2/provider/apple.d.mts.map +1 -1
- package/dist/oauth2/provider/apple.mjs +1 -2
- package/dist/oauth2/provider/common.cjs +2 -3
- package/dist/oauth2/provider/common.cjs.map +1 -1
- package/dist/oauth2/provider/common.d.cts +0 -2
- package/dist/oauth2/provider/common.d.cts.map +1 -1
- package/dist/oauth2/provider/common.d.mts +0 -2
- package/dist/oauth2/provider/common.d.mts.map +1 -1
- package/dist/oauth2/provider/common.mjs +2 -3
- package/dist/oauth2/provider/common.mjs.map +1 -1
- package/dist/oauth2/provider/facebook.cjs +3 -6
- package/dist/oauth2/provider/facebook.cjs.map +1 -1
- package/dist/oauth2/provider/facebook.d.cts +0 -3
- package/dist/oauth2/provider/facebook.d.cts.map +1 -1
- package/dist/oauth2/provider/facebook.d.mts +0 -3
- package/dist/oauth2/provider/facebook.d.mts.map +1 -1
- package/dist/oauth2/provider/facebook.mjs +3 -6
- package/dist/oauth2/provider/facebook.mjs.map +1 -1
- package/dist/oauth2/provider/github.cjs +1 -2
- package/dist/oauth2/provider/github.d.cts +0 -2
- package/dist/oauth2/provider/github.d.cts.map +1 -1
- package/dist/oauth2/provider/github.d.mts +0 -2
- package/dist/oauth2/provider/github.d.mts.map +1 -1
- package/dist/oauth2/provider/github.mjs +1 -2
- package/dist/oauth2/provider/google.cjs +1 -2
- package/dist/oauth2/provider/google.d.cts +0 -2
- package/dist/oauth2/provider/google.d.cts.map +1 -1
- package/dist/oauth2/provider/google.d.mts +0 -2
- package/dist/oauth2/provider/google.d.mts.map +1 -1
- package/dist/oauth2/provider/google.mjs +1 -2
- package/dist/oauth2/provider/wechat.cjs +1 -2
- package/dist/oauth2/provider/wechat.d.cts +0 -2
- package/dist/oauth2/provider/wechat.d.cts.map +1 -1
- package/dist/oauth2/provider/wechat.d.mts +0 -2
- package/dist/oauth2/provider/wechat.d.mts.map +1 -1
- package/dist/oauth2/provider/wechat.mjs +1 -2
- package/dist/oauth2/provider/x.cjs +7 -6
- package/dist/oauth2/provider/x.cjs.map +1 -1
- package/dist/oauth2/provider/x.d.cts +3 -4
- package/dist/oauth2/provider/x.d.cts.map +1 -1
- package/dist/oauth2/provider/x.d.mts +3 -4
- package/dist/oauth2/provider/x.d.mts.map +1 -1
- package/dist/oauth2/provider/x.mjs +7 -6
- package/dist/oauth2/provider/x.mjs.map +1 -1
- package/dist/oauth2/types.cjs +1 -2
- package/dist/oauth2/types.cjs.map +1 -1
- package/dist/oauth2/types.d.cts +5 -2
- package/dist/oauth2/types.d.cts.map +1 -1
- package/dist/oauth2/types.d.mts +5 -2
- package/dist/oauth2/types.d.mts.map +1 -1
- package/dist/oauth2/types.mjs +1 -2
- package/dist/oauth2/types.mjs.map +1 -1
- package/dist/session/common.cjs +11 -15
- package/dist/session/common.cjs.map +1 -1
- package/dist/session/common.d.cts +7 -5
- package/dist/session/common.d.cts.map +1 -1
- package/dist/session/common.d.mts +7 -5
- package/dist/session/common.d.mts.map +1 -1
- package/dist/session/common.mjs +11 -14
- package/dist/session/common.mjs.map +1 -1
- package/dist/session/db.cjs +3 -4
- package/dist/session/db.cjs.map +1 -1
- package/dist/session/db.d.cts +0 -2
- package/dist/session/db.d.cts.map +1 -1
- package/dist/session/db.d.mts +0 -2
- package/dist/session/db.d.mts.map +1 -1
- package/dist/session/db.mjs +4 -5
- package/dist/session/db.mjs.map +1 -1
- package/dist/session/map-session.cjs +1 -2
- package/dist/session/map-session.d.cts +0 -2
- package/dist/session/map-session.d.cts.map +1 -1
- package/dist/session/map-session.d.mts +0 -2
- package/dist/session/map-session.d.mts.map +1 -1
- package/dist/session/map-session.mjs +1 -2
- package/dist/session/redis.cjs +7 -10
- package/dist/session/redis.cjs.map +1 -1
- package/dist/session/redis.d.cts +0 -2
- package/dist/session/redis.d.cts.map +1 -1
- package/dist/session/redis.d.mts +0 -2
- package/dist/session/redis.d.mts.map +1 -1
- package/dist/session/redis.mjs +8 -11
- package/dist/session/redis.mjs.map +1 -1
- package/dist/session/types.d.cts +0 -14
- package/dist/session/types.d.cts.map +1 -1
- package/dist/session/types.d.mts +0 -14
- package/dist/session/types.d.mts.map +1 -1
- package/dist/utils/http.cjs +1 -2
- package/dist/utils/http.d.cts +0 -2
- package/dist/utils/http.d.cts.map +1 -1
- package/dist/utils/http.d.mts +0 -2
- package/dist/utils/http.d.mts.map +1 -1
- package/dist/utils/http.mjs +1 -2
- package/dist/utils/oauth2.cjs +1 -2
- package/dist/utils/oauth2.d.cts +0 -2
- package/dist/utils/oauth2.d.cts.map +1 -1
- package/dist/utils/oauth2.d.mts +0 -2
- package/dist/utils/oauth2.d.mts.map +1 -1
- package/dist/utils/oauth2.mjs +1 -2
- package/dist/utils/timing.cjs +1 -2
- package/dist/utils/timing.d.cts +0 -2
- package/dist/utils/timing.d.cts.map +1 -1
- package/dist/utils/timing.d.mts +0 -2
- package/dist/utils/timing.d.mts.map +1 -1
- package/dist/utils/timing.mjs +1 -2
- package/dist/utils/valid.cjs +35 -54
- package/dist/utils/valid.cjs.map +1 -1
- package/dist/utils/valid.d.cts +8 -25
- package/dist/utils/valid.d.cts.map +1 -1
- package/dist/utils/valid.d.mts +8 -25
- package/dist/utils/valid.d.mts.map +1 -1
- package/dist/utils/valid.mjs +33 -52
- package/dist/utils/valid.mjs.map +1 -1
- package/package.json +4 -4
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, randomUUID } from "node:crypto";
|
|
10
14
|
import { invariant } from "@shware/utils";
|
|
11
|
-
|
|
15
|
+
import { extend, literal, object, regex, string } from "zod/mini";
|
|
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,21 +218,13 @@ 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
230
|
const code_verifier = randomUUID();
|
|
@@ -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,7 @@ var Auth = class {
|
|
|
205
274
|
state,
|
|
206
275
|
pkce
|
|
207
276
|
});
|
|
208
|
-
const
|
|
209
|
-
const session = sessionId ? await this.repository.findById(sessionId) ?? this.repository.createSession() : this.repository.createSession();
|
|
277
|
+
const session = await this.loadSession(request) ?? this.repository.createSession();
|
|
210
278
|
mark("fetch_session");
|
|
211
279
|
const authorizationRequest = {
|
|
212
280
|
state,
|
|
@@ -215,56 +283,38 @@ var Auth = class {
|
|
|
215
283
|
additionalParameters: pkce,
|
|
216
284
|
...attributes ? { attributes } : {}
|
|
217
285
|
};
|
|
218
|
-
|
|
219
|
-
session.setAttribute(this.ATTR_OAUTH2_AUTHORIZATION_REQUEST, value);
|
|
286
|
+
session.setAttribute(this.ATTR_OAUTH2_AUTHORIZATION_REQUEST, JSON.stringify(authorizationRequest));
|
|
220
287
|
await this.repository.save(session);
|
|
221
288
|
mark("save_session");
|
|
222
289
|
const response = new Response(null, {
|
|
223
290
|
status: 302,
|
|
224
291
|
headers: { location: uri.href }
|
|
225
292
|
});
|
|
226
|
-
const maxAge = session.getMaxInactiveInterval();
|
|
227
|
-
setCookie(response, this.cookieName, session.getId(), {
|
|
228
|
-
...this.cookieOptions,
|
|
229
|
-
maxAge
|
|
230
|
-
});
|
|
231
293
|
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
|
-
};
|
|
294
|
+
return this.withSession(response, session);
|
|
295
|
+
});
|
|
241
296
|
loginOAuth2Code = async (request, handler) => {
|
|
242
297
|
invariant(this.oauth2Client, "oauth2Client is not initialized");
|
|
243
298
|
const { mark, setTiming } = timing({ enabled: this.timing });
|
|
244
|
-
const
|
|
245
|
-
const session = sessionId ? await this.repository.findById(sessionId) : null;
|
|
299
|
+
const session = await this.loadSession(request);
|
|
246
300
|
if (!session) return this.redirect("invalid_request", "session not found");
|
|
247
301
|
mark("load_session");
|
|
248
302
|
const json = session.getAttribute(this.ATTR_OAUTH2_AUTHORIZATION_REQUEST);
|
|
249
303
|
if (!json) return this.redirect("invalid_request", "authorization request not found");
|
|
250
304
|
const cached = JSON.parse(json);
|
|
251
305
|
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);
|
|
306
|
+
const data = isFormPost(request) ? Object.fromEntries(new URLSearchParams(await request.text())) : query(request);
|
|
257
307
|
const parsed = oauth2RedirectQuerySchema.safeParse(data);
|
|
258
308
|
if (!parsed.success) return this.redirect("invalid_request", "invalid redirect query");
|
|
259
309
|
if (parsed.data.error) {
|
|
260
310
|
if (parsed.data.state && parsed.data.state !== cached.state) return this.redirect("invalid_request", "redirect state mismatch");
|
|
261
311
|
return this.redirect(safeErrorType(parsed.data.error, "server_error"), parsed.data.error_description);
|
|
262
312
|
}
|
|
263
|
-
|
|
264
|
-
if (
|
|
313
|
+
const { code, state, user } = parsed.data;
|
|
314
|
+
if (!code || !state) return this.redirect("invalid_request", "invalid redirect query");
|
|
315
|
+
if (state !== cached.state) return this.redirect("invalid_request", "redirect state mismatch");
|
|
265
316
|
if (provider !== cached.provider) return this.redirect("invalid_request", "redirect provider mismatch");
|
|
266
317
|
mark("validate_session");
|
|
267
|
-
const { code, user } = parsed.data;
|
|
268
318
|
let signedIn;
|
|
269
319
|
try {
|
|
270
320
|
const { clientId, token } = await this.oauth2Client.exchangeAuthorizationCode({
|
|
@@ -287,8 +337,7 @@ var Auth = class {
|
|
|
287
337
|
attributes: cached.attributes ?? {}
|
|
288
338
|
});
|
|
289
339
|
mark("save_principal");
|
|
290
|
-
signedIn = this.
|
|
291
|
-
await this.repository.save(signedIn);
|
|
340
|
+
signedIn = await this.signIn(principal, session);
|
|
292
341
|
await this.repository.deleteById(session.getId());
|
|
293
342
|
mark("update_session");
|
|
294
343
|
} catch (error) {
|
|
@@ -297,6 +346,10 @@ var Auth = class {
|
|
|
297
346
|
const type = CALLBACK_ERROR_TYPES.has(error.error) ? error.error : "server_error";
|
|
298
347
|
return this.redirect(type);
|
|
299
348
|
}
|
|
349
|
+
if (error instanceof AuthenticationError) {
|
|
350
|
+
console.warn("OAuth2 sign-in refused", error.reason, error.metadata);
|
|
351
|
+
return this.redirect("access_denied", void 0, error);
|
|
352
|
+
}
|
|
300
353
|
console.error("OAuth2 callback failed", error);
|
|
301
354
|
return this.redirect("server_error");
|
|
302
355
|
}
|
|
@@ -307,328 +360,157 @@ var Auth = class {
|
|
|
307
360
|
status: 302,
|
|
308
361
|
headers: { location: uri.href }
|
|
309
362
|
});
|
|
310
|
-
const maxAge = signedIn.getMaxInactiveInterval();
|
|
311
|
-
setCookie(response, this.cookieName, signedIn.getId(), {
|
|
312
|
-
...this.cookieOptions,
|
|
313
|
-
maxAge
|
|
314
|
-
});
|
|
315
363
|
setTiming(response);
|
|
316
|
-
return response;
|
|
364
|
+
return this.withSession(response, signedIn);
|
|
317
365
|
};
|
|
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;
|
|
366
|
+
/** The shared tail of the JSON login routes: the host's handler, then a signed-in session. */
|
|
367
|
+
async authorize(request, handler, authorization, setTiming) {
|
|
368
|
+
const principal = await handler(request, authorization);
|
|
369
|
+
const session = await this.signIn(principal);
|
|
370
|
+
const response = Response.json(principal);
|
|
371
|
+
setTiming(response);
|
|
372
|
+
return this.withSession(response, session);
|
|
333
373
|
}
|
|
334
|
-
loginOAuth2Native =
|
|
374
|
+
loginOAuth2Native = (request, handler) => this.guard(async () => {
|
|
335
375
|
invariant(this.oauth2Client, "oauth2Client is not initialized");
|
|
336
376
|
const { mark, setTiming } = timing({ enabled: this.timing });
|
|
337
377
|
const { provider } = param(request, PATH.LOGIN_OAUTH2_NATIVE);
|
|
338
|
-
const body = await request
|
|
378
|
+
const body = await readJson(request);
|
|
339
379
|
const attributes = bodyAttributes(body);
|
|
340
|
-
if (!attributes) return Response.json(INVALID_ATTRIBUTES, { status: 400 });
|
|
341
380
|
const { attributes: _, ...credentials } = body;
|
|
342
381
|
const { code_verifier } = credentials;
|
|
343
|
-
if (code_verifier !== void 0 && (typeof code_verifier !== "string" || !CODE_VERIFIER_FORMAT.test(code_verifier)))
|
|
382
|
+
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
383
|
const { clientId, userInfo, token } = await this.oauth2Client.loginOAuth2Native({
|
|
345
384
|
provider,
|
|
346
385
|
credentials
|
|
347
386
|
});
|
|
348
387
|
mark("login_oauth2_native");
|
|
349
388
|
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));
|
|
389
|
+
if (typeof nonce === "string" && nonce) {
|
|
390
|
+
await this.consumeNonce(nonce, provider);
|
|
356
391
|
mark("check_nonce");
|
|
357
392
|
}
|
|
358
|
-
|
|
393
|
+
return this.authorize(request, handler, {
|
|
359
394
|
provider,
|
|
360
395
|
clientId,
|
|
361
396
|
userInfo,
|
|
362
397
|
token,
|
|
363
398
|
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") => {
|
|
399
|
+
}, setTiming);
|
|
400
|
+
});
|
|
401
|
+
loginOAuth2Onetap = (request, handler, provider = "google") => this.guard(async () => {
|
|
382
402
|
invariant(this.oauth2Client, "oauth2Client is not initialized");
|
|
383
403
|
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;
|
|
404
|
+
const body = (request.headers.get("content-type") ?? "").includes("application/json") ? await readJson(request) : await readForm(request);
|
|
405
|
+
const { attributes = {}, ...parsed } = parse(oneTapBodySchema, body);
|
|
406
|
+
let clientId;
|
|
407
|
+
let token;
|
|
408
|
+
let userInfo;
|
|
400
409
|
if (parsed.credential) {
|
|
401
|
-
|
|
410
|
+
({clientId, token, userInfo} = await this.oauth2Client.getTokenInfo({
|
|
402
411
|
provider,
|
|
403
412
|
credential: parsed.credential
|
|
404
|
-
});
|
|
413
|
+
}));
|
|
405
414
|
mark("verify_credential");
|
|
406
415
|
const { nonce } = userInfo.data;
|
|
407
416
|
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));
|
|
417
|
+
await this.consumeNonce(nonce, provider);
|
|
413
418
|
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
|
-
});
|
|
419
|
+
} else if (this.onetapRequireNonce) throw new AuthenticationError("NONCE_MISSING");
|
|
425
420
|
} else if (parsed.code) {
|
|
426
|
-
|
|
421
|
+
({clientId, token} = await this.oauth2Client.exchangeAuthorizationCode({
|
|
427
422
|
provider,
|
|
428
423
|
code: parsed.code
|
|
429
|
-
});
|
|
430
|
-
|
|
424
|
+
}));
|
|
425
|
+
userInfo = await this.oauth2Client.getUserInfo({
|
|
431
426
|
provider,
|
|
432
427
|
clientId,
|
|
433
|
-
|
|
434
|
-
provider,
|
|
435
|
-
clientId,
|
|
436
|
-
token
|
|
437
|
-
}),
|
|
438
|
-
token,
|
|
439
|
-
attributes
|
|
428
|
+
token
|
|
440
429
|
});
|
|
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) => {
|
|
430
|
+
} else throw invalidArgument("INVALID_ARGUMENT", "credential or code is required");
|
|
431
|
+
return this.authorize(request, handler, {
|
|
432
|
+
provider,
|
|
433
|
+
clientId,
|
|
434
|
+
userInfo,
|
|
435
|
+
token,
|
|
436
|
+
attributes
|
|
437
|
+
}, setTiming);
|
|
438
|
+
});
|
|
439
|
+
sendEmailVerificationCode = (request, sender) => this.guard(async () => {
|
|
459
440
|
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, {
|
|
441
|
+
const { email, turnstileToken } = parse(sendEmailVerificationCodeSchema, await readJson(request));
|
|
442
|
+
await verifyTurnstileToken(this.turnstileSecretKey, turnstileToken);
|
|
443
|
+
await this.emailCodes.send(email, sender);
|
|
444
|
+
return Response.json({});
|
|
445
|
+
});
|
|
446
|
+
verifyEmailCode = (email, verificationCode) => this.emailCodes.verify(email, verificationCode);
|
|
447
|
+
loginEmail = (request, handler) => this.guard(async () => {
|
|
448
|
+
const { setTiming } = timing({ enabled: this.timing });
|
|
449
|
+
const { email, verificationCode, attributes = {} } = parse(loginEmailSchema, await readJson(request));
|
|
450
|
+
await this.emailCodes.verify(email, verificationCode);
|
|
451
|
+
const claims = {
|
|
452
|
+
sub: email,
|
|
453
|
+
email,
|
|
454
|
+
email_verified: true
|
|
455
|
+
};
|
|
456
|
+
return this.authorize(request, handler, {
|
|
502
457
|
provider: "EMAIL",
|
|
503
458
|
userInfo: {
|
|
504
|
-
claims
|
|
505
|
-
sub: email,
|
|
506
|
-
email,
|
|
507
|
-
email_verified: true
|
|
508
|
-
},
|
|
459
|
+
claims,
|
|
509
460
|
data: {}
|
|
510
461
|
},
|
|
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
|
-
};
|
|
462
|
+
attributes
|
|
463
|
+
}, setTiming);
|
|
464
|
+
});
|
|
527
465
|
/**
|
|
528
|
-
* Step 1 of the desktop / native app sign-in handshake. The caller already
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
* desktop client via a loopback redirect.
|
|
466
|
+
* Step 1 of the desktop / native app sign-in handshake. The caller already completed normal
|
|
467
|
+
* sign-in on the web (so a cookie session exists); this issues a 5-minute single-use code that
|
|
468
|
+
* the web page hands off to the desktop client via a loopback redirect.
|
|
532
469
|
*
|
|
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.
|
|
470
|
+
* PKCE (RFC 7636, S256 only): the desktop generates a random `code_verifier` before opening the
|
|
471
|
+
* browser, derives `code_challenge = base64url(sha256(verifier))`, and threads the challenge
|
|
472
|
+
* through the URL → web page → here. The challenge is pinned to the code; `desktopExchange`
|
|
473
|
+
* enforces that the caller knows the matching verifier. This blocks an attacker who only
|
|
474
|
+
* intercepts the auth code (browser history, malicious extension, packet capture on loopback)
|
|
475
|
+
* from exchanging it.
|
|
540
476
|
*
|
|
541
|
-
* Requires an authenticated cookie session
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
* upstream sign-in flow already passed CAPTCHA / OAuth / verification.
|
|
477
|
+
* Requires an authenticated cookie session: anonymous callers get 401. Throttling / abuse
|
|
478
|
+
* protection on this endpoint is the API layer's job (rate-limit per principal); the SDK does
|
|
479
|
+
* not gate it because the upstream sign-in flow already passed CAPTCHA / OAuth / verification.
|
|
545
480
|
*/
|
|
546
|
-
desktopAuthorize = async (
|
|
481
|
+
desktopAuthorize = (request) => this.guard(async () => {
|
|
547
482
|
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);
|
|
483
|
+
if (!principal) throw unauthenticated("Sign in first");
|
|
484
|
+
const { code_challenge } = parse(desktopAuthorizeSchema, await readJson(request));
|
|
485
|
+
const code = await this.desktopCodes.issue(principal, code_challenge);
|
|
568
486
|
return Response.json({ code });
|
|
569
|
-
};
|
|
487
|
+
});
|
|
570
488
|
/**
|
|
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.
|
|
489
|
+
* Step 2 of the desktop / native app sign-in handshake. The desktop client hands back the auth
|
|
490
|
+
* code it received via the loopback callback, and we mint a fresh session for it, independent
|
|
491
|
+
* of the web cookie session that originally authorized the code, so logging out on either side
|
|
492
|
+
* does not cascade to the other.
|
|
576
493
|
*
|
|
577
|
-
* Cookie attributes:
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
* backend this combination is rejected by Chromium — see the desktop
|
|
583
|
-
* sign-in PRD for workarounds.
|
|
494
|
+
* Cookie attributes: `SameSite=None; Secure` so the cookie survives cross-site fetches from the
|
|
495
|
+
* desktop renderer's `file://` / `app://` origin. The configured `cookieOptions` provide
|
|
496
|
+
* everything else (`domain`, `path`, `httpOnly`); the override is targeted at the
|
|
497
|
+
* SameSite/Secure pair only. In a non-HTTPS dev backend this combination is rejected by
|
|
498
|
+
* Chromium, see the desktop sign-in PRD for workarounds.
|
|
584
499
|
*
|
|
585
|
-
* No CSRF check needed
|
|
586
|
-
*
|
|
500
|
+
* No CSRF check needed: the request carries no cookie, the auth code itself is the credential,
|
|
501
|
+
* and the code's single-use removal blocks replay.
|
|
587
502
|
*/
|
|
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);
|
|
503
|
+
desktopExchange = (request) => this.guard(async () => {
|
|
504
|
+
const { code, code_verifier } = parse(desktopExchangeSchema, await readJson(request));
|
|
505
|
+
const principal = await this.desktopCodes.redeem(code, code_verifier);
|
|
506
|
+
const session = await this.signIn(principal);
|
|
614
507
|
session.setAttribute("client_type", "desktop");
|
|
615
508
|
await this.repository.save(session);
|
|
616
|
-
|
|
617
|
-
const maxAge = session.getMaxInactiveInterval();
|
|
618
|
-
setCookie(response, this.cookieName, session.getId(), {
|
|
619
|
-
...this.cookieOptions,
|
|
509
|
+
return this.withSession(Response.json({ ok: true }), session, {
|
|
620
510
|
sameSite: "none",
|
|
621
|
-
secure: true
|
|
622
|
-
maxAge
|
|
511
|
+
secure: true
|
|
623
512
|
});
|
|
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
|
-
};
|
|
513
|
+
});
|
|
632
514
|
kick = async (principal) => {
|
|
633
515
|
const sessions = await this.repository.findByPrincipalName(principal.name);
|
|
634
516
|
for (const sessionId of sessions.keys()) await this.repository.deleteById(sessionId);
|
|
@@ -646,47 +528,26 @@ var Auth = class {
|
|
|
646
528
|
session.setLastAccessedTime(now);
|
|
647
529
|
await this.repository.save(session);
|
|
648
530
|
}
|
|
649
|
-
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
if (!session) return false;
|
|
654
|
-
if (!session.getAttribute("org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME")) return false;
|
|
531
|
+
getPrincipal = async (request) => {
|
|
532
|
+
const session = await this.loadSession(request);
|
|
533
|
+
const principal = session && this.principalOf(session);
|
|
534
|
+
if (!principal) return null;
|
|
655
535
|
await this.touch(session);
|
|
656
|
-
return
|
|
536
|
+
return principal;
|
|
537
|
+
};
|
|
538
|
+
isAuthenticated = async (request) => {
|
|
539
|
+
return await this.getPrincipal(request) !== null;
|
|
657
540
|
};
|
|
658
541
|
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;
|
|
542
|
+
const session = await this.loadSession(request);
|
|
543
|
+
if (session || !create) return session;
|
|
544
|
+
const created = this.repository.createSession();
|
|
545
|
+
await this.repository.save(created);
|
|
546
|
+
return created;
|
|
676
547
|
};
|
|
677
548
|
deleteSession = async (sessionId) => {
|
|
678
549
|
await this.repository.deleteById(sessionId);
|
|
679
550
|
};
|
|
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
551
|
listSessions = async (principal) => {
|
|
691
552
|
const sessions = await this.repository.findByPrincipalName(principal.name);
|
|
692
553
|
return Array.from(sessions.values());
|
|
@@ -696,7 +557,6 @@ var Auth = class {
|
|
|
696
557
|
return Response.json({});
|
|
697
558
|
};
|
|
698
559
|
};
|
|
699
|
-
//#endregion
|
|
700
560
|
export { Auth, PATH };
|
|
701
561
|
|
|
702
562
|
//# sourceMappingURL=index.mjs.map
|