@protobase/server 0.25.11 → 0.26.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/dist/admin-options.d.ts +7 -0
- package/dist/better-auth/app-tokens.js +2 -2
- package/dist/better-auth/authenticator.js +1 -1
- package/dist/better-auth/create-auth.d.ts +121 -2
- package/dist/better-auth/create-auth.js +70 -51
- package/dist/better-auth/email-confirmed.js +31 -0
- package/dist/better-auth/organizations/accept-invitation.js +1 -1
- package/dist/better-auth/organizations/app-roles.js +1 -1
- package/dist/better-auth/organizations/options.js +1 -1
- package/dist/better-auth/organizations/organization-plugin.js +1 -1
- package/dist/better-auth/organizations/session-organization.js +1 -1
- package/dist/better-auth/passkey-plugin.js +1 -1
- package/dist/better-auth/policy-plugin.js +27 -12
- package/dist/better-auth/roles/active-role.js +1 -1
- package/dist/better-auth/sign-up/sign-up-account.js +26 -0
- package/dist/better-auth/sign-up/sign-up-plugin.js +6 -14
- package/dist/better-auth/staff-plugin.js +1 -1
- package/dist/better-auth/staff-sign-in-endpoints.js +3 -3
- package/dist/better-auth/status-route.js +1 -1
- package/dist/better-auth/user-table.d.ts +1 -0
- package/dist/better-auth/user-table.js +1 -0
- package/dist/better-auth/users/user-endpoints.js +1 -1
- package/dist/create-admin.d.ts +10 -1
- package/dist/create-admin.js +26 -2
- package/dist/gotrue/access-tokens.js +15 -0
- package/dist/gotrue/auth-codes.js +33 -0
- package/dist/gotrue/bearer.js +22 -0
- package/dist/gotrue/deps.js +8 -0
- package/dist/gotrue/email-code-routes.js +121 -0
- package/dist/gotrue/errors.js +52 -0
- package/dist/gotrue/forward.js +45 -0
- package/dist/gotrue/keys.js +14 -0
- package/dist/gotrue/pending-sign-up.js +26 -0
- package/dist/gotrue/provider-routes.js +79 -0
- package/dist/gotrue/request-body.js +22 -0
- package/dist/gotrue/routes.js +32 -0
- package/dist/gotrue/session-json.js +34 -0
- package/dist/gotrue/sessions-plugin.js +83 -0
- package/dist/gotrue/token-route.js +42 -0
- package/dist/gotrue/user-json.js +46 -0
- package/dist/gotrue/user-routes.js +99 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -7
- package/dist/live/notices-sql.js +4 -2
- package/dist/realtime/change-checker.js +102 -0
- package/dist/realtime/change-filter.js +106 -0
- package/dist/realtime/change-hub.js +100 -0
- package/dist/realtime/change-payload.js +31 -0
- package/dist/realtime/phoenix-message.js +63 -0
- package/dist/realtime/postgres-changes.js +62 -0
- package/dist/realtime/realtime-app.js +31 -0
- package/dist/realtime/realtime-routes.js +47 -0
- package/dist/realtime/realtime-socket.js +135 -0
- package/dist/rest/caller-settings.js +27 -0
- package/package.json +10 -5
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { isRefusedForStaff, isStaffSession, staffCannotChange } from "./staff-checks.js";
|
|
2
1
|
import { adminOnly } from "./admin-only.js";
|
|
2
|
+
import { isRefusedForStaff, isStaffSession, staffCannotChange } from "./staff-checks.js";
|
|
3
3
|
import { endStaffSignIn, listStaffSignIns, staffSignInOfSession, staffSignInSchema } from "./staff-log.js";
|
|
4
4
|
import { staffSignInEndpoints } from "./staff-sign-in-endpoints.js";
|
|
5
5
|
import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { reasonProblem, staffClaimsRefusal, staffLabel } from "./staff-checks.js";
|
|
2
|
-
import { operatorClient, randomValue } from "./operator-provider.js";
|
|
3
|
-
import { sendAuthEmail } from "./auth-mail/send-auth-email.js";
|
|
4
1
|
import { effectiveSignInPolicy } from "./sign-in-policy.js";
|
|
5
2
|
import { readSignInPolicy } from "./policy-store.js";
|
|
6
3
|
import { policyRefusal, staffSignInPath } from "./sign-in-enforcement.js";
|
|
4
|
+
import { reasonProblem, staffClaimsRefusal, staffLabel } from "./staff-checks.js";
|
|
5
|
+
import { operatorClient, randomValue } from "./operator-provider.js";
|
|
6
|
+
import { sendAuthEmail } from "./auth-mail/send-auth-email.js";
|
|
7
7
|
import { logStaffSignIn } from "./staff-log.js";
|
|
8
8
|
import * as z$1 from "zod";
|
|
9
9
|
import { APIError, createAuthEndpoint } from "better-auth/api";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { signInPasskeyDomains } from "./passkey-domain.js";
|
|
2
1
|
import { effectiveSignInPolicy } from "./sign-in-policy.js";
|
|
3
2
|
import { readSignInPolicy } from "./policy-store.js";
|
|
3
|
+
import { signInPasskeyDomains } from "./passkey-domain.js";
|
|
4
4
|
import { roleLabels } from "./roles/role-labels.js";
|
|
5
5
|
import { hasUsers } from "./users.js";
|
|
6
6
|
import { Hono } from "hono";
|
|
@@ -5,6 +5,7 @@ declare const userFields: {
|
|
|
5
5
|
readonly name: "name";
|
|
6
6
|
readonly email: "email";
|
|
7
7
|
readonly email_verified: "emailVerified";
|
|
8
|
+
readonly email_confirmed_at: "emailConfirmedAt";
|
|
8
9
|
readonly image: "image";
|
|
9
10
|
readonly created_at: "createdAt";
|
|
10
11
|
readonly updated_at: "updatedAt";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { adminOnly } from "../admin-only.js";
|
|
1
2
|
import { invitationLink, invitationToken } from "../invitations/invitation-token.js";
|
|
2
3
|
import { sendAuthEmail } from "../auth-mail/send-auth-email.js";
|
|
3
|
-
import { adminOnly } from "../admin-only.js";
|
|
4
4
|
import { createAppInvitation, deleteAppInvitation, listAppInvitations } from "./app-invitation-store.js";
|
|
5
5
|
import { roleLabels } from "../roles/role-labels.js";
|
|
6
6
|
import * as z$1 from "zod";
|
package/dist/create-admin.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ import { FunctionSource } from "./functions/define-function.js";
|
|
|
13
13
|
import { AdminOptions } from "./admin-options.js";
|
|
14
14
|
import { BottomBarSource, UserMenuSource } from "@protobase/schema";
|
|
15
15
|
import { Db } from "@protobase/query";
|
|
16
|
+
import { JWTPayload } from "jose";
|
|
17
|
+
import { UpgradeWebSocket } from "hono/ws";
|
|
16
18
|
//#region src/create-admin.d.ts
|
|
17
19
|
export type CreateAdminInput = {
|
|
18
20
|
resources: ResourceSource[];
|
|
@@ -58,7 +60,14 @@ export type CreateAdminInput = {
|
|
|
58
60
|
formsDifficulty?: Difficulty;
|
|
59
61
|
/** @internal The users comments name, mention and mail, which tests fake; from `auth` otherwise. */
|
|
60
62
|
commentUsers?: CommentUsers;
|
|
63
|
+
/** @internal Verifies a user's token on `/realtime/v1` and gives its claims, which tests fake; `auth`'s tokens otherwise. */
|
|
64
|
+
verifyToken?: (token: string) => Promise<JWTPayload>;
|
|
65
|
+
/**
|
|
66
|
+
* The host's WebSocket upgrade, such as `@hono/node-server`'s, with which an app with keys and live resources serves
|
|
67
|
+
* supabase-js's Realtime at `/realtime/v1/websocket`; without it, the app serves none.
|
|
68
|
+
*/
|
|
69
|
+
upgradeWebSocket?: UpgradeWebSocket;
|
|
61
70
|
};
|
|
62
71
|
/** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
|
|
63
|
-
export declare const createAdmin: ({ resources: appResources, views: appViews, pages, userMenu, bottomBars, db, authenticate, auth, functions, files: filesOptions, events, history: historyOptions, jobs, subscriptions, webhooks: webhooksOptions, bundlePlugins, fieldTypes, options, formsDifficulty, commentUsers: givenCommentUsers }: CreateAdminInput) => Admin;
|
|
72
|
+
export declare const createAdmin: ({ resources: appResources, views: appViews, pages, userMenu, bottomBars, db, authenticate, auth, functions, files: filesOptions, events, history: historyOptions, jobs, subscriptions, webhooks: webhooksOptions, bundlePlugins, fieldTypes, options, formsDifficulty, commentUsers: givenCommentUsers, verifyToken, upgradeWebSocket }: CreateAdminInput) => Admin;
|
|
64
73
|
//#endregion
|
package/dist/create-admin.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { aiModel } from "./ai/ai-model.js";
|
|
2
2
|
import { hasAiActions, hasAiFields } from "./ai/ai-fields.js";
|
|
3
|
+
import { unauthorized } from "./problem.js";
|
|
3
4
|
import { fillHook } from "./ai/fill-hook.js";
|
|
4
5
|
import { createErrorHandler } from "./error-handler.js";
|
|
5
6
|
import { isCaptured } from "./changes/capture-sql.js";
|
|
@@ -10,10 +11,17 @@ import { builtInAssistant } from "./assistant/built-in-assistant.js";
|
|
|
10
11
|
import { consoleAuditQueue } from "./audit/console-queue.js";
|
|
11
12
|
import { webauthnPath } from "./better-auth/origins.js";
|
|
12
13
|
import { apiCors, readCorsOrigins } from "./api-cors.js";
|
|
14
|
+
import { apiKeySettings } from "./api-keys/api-key-settings.js";
|
|
15
|
+
import { apiKeyCaller } from "./api-keys/resolve-caller.js";
|
|
16
|
+
import { jwtVerifier } from "./auth/jwt.js";
|
|
13
17
|
import { readAppName } from "./app-name.js";
|
|
18
|
+
import { goTruePath } from "./gotrue/keys.js";
|
|
14
19
|
import { authBasePath } from "./better-auth/auth-base-path.js";
|
|
20
|
+
import { betterAuthTokens } from "./better-auth/authenticator.js";
|
|
21
|
+
import { realtimeApp } from "./realtime/realtime-app.js";
|
|
15
22
|
import { organizationRoutes } from "./better-auth/organizations/admin-routes.js";
|
|
16
23
|
import { statusRoute } from "./better-auth/status-route.js";
|
|
24
|
+
import { goTrueRoutes } from "./gotrue/routes.js";
|
|
17
25
|
import { changesSchema } from "./changes/events-options.js";
|
|
18
26
|
import { appOrigin } from "./comments/app-origin.js";
|
|
19
27
|
import { betterAuthActorNames } from "./history/actor-names.js";
|
|
@@ -66,7 +74,7 @@ const declaredRoles = (auth, roles) => {
|
|
|
66
74
|
return names.length > 0 ? [...new Set(names)] : void 0;
|
|
67
75
|
};
|
|
68
76
|
/** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
|
|
69
|
-
const createAdmin = ({ resources: appResources, views: appViews = [], pages = [], userMenu, bottomBars = [], db, authenticate, auth, functions = {}, files: filesOptions, events, history: historyOptions, jobs = [], subscriptions = [], webhooks: webhooksOptions, bundlePlugins = [], fieldTypes = {}, options = {}, formsDifficulty, commentUsers: givenCommentUsers }) => {
|
|
77
|
+
const createAdmin = ({ resources: appResources, views: appViews = [], pages = [], userMenu, bottomBars = [], db, authenticate, auth, functions = {}, files: filesOptions, events, history: historyOptions, jobs = [], subscriptions = [], webhooks: webhooksOptions, bundlePlugins = [], fieldTypes = {}, options = {}, formsDifficulty, commentUsers: givenCommentUsers, verifyToken, upgradeWebSocket }) => {
|
|
70
78
|
const basePath = options.basePath ?? "/api/v1";
|
|
71
79
|
const appModels = appResources.map((resource) => resource.toModel());
|
|
72
80
|
const withComments = appModels.some((model) => model.comments);
|
|
@@ -125,6 +133,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
125
133
|
resources: registry.entries.map((entry) => entry.name),
|
|
126
134
|
pages: pageModels.map((entry) => entry.name)
|
|
127
135
|
});
|
|
136
|
+
const apiKeys = apiKeySettings(options.apiKeys, globalThis.process?.env ?? {});
|
|
128
137
|
const files = createFilesRuntime({
|
|
129
138
|
...filesOptions && { options: filesOptions },
|
|
130
139
|
env: globalThis.process?.env ?? {},
|
|
@@ -233,6 +242,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
233
242
|
db
|
|
234
243
|
}));
|
|
235
244
|
app.on(["GET", "POST"], `${authBasePath}/*`, (c) => auth.handler(c.req.raw));
|
|
245
|
+
app.route(goTruePath, goTrueRoutes(auth));
|
|
236
246
|
if (auth.origins.length > 1) app.get(webauthnPath, (c) => c.json({ origins: auth.origins }));
|
|
237
247
|
}
|
|
238
248
|
if (assistant?.kind === "built-in") {
|
|
@@ -261,12 +271,26 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
261
271
|
app.route("/", fileRoutes(deps, authenticate));
|
|
262
272
|
if (forms) app.route(`${systemPath}/forms`, formRoutes(deps, forms));
|
|
263
273
|
if (appModels.some((model) => model.live)) {
|
|
274
|
+
const report = options.onUnhandledError ?? ((error) => console.error(error));
|
|
264
275
|
const feed = tableFeed({
|
|
265
276
|
db,
|
|
266
277
|
schema: changes,
|
|
267
|
-
report
|
|
278
|
+
report
|
|
268
279
|
});
|
|
269
280
|
app.route(systemPath || "/", guarded((live) => void live.route("/", liveRoutes(feed, (session) => visibleNotices(deps, session))), withAnonymous(authenticate, options.roles)));
|
|
281
|
+
if (upgradeWebSocket && (apiKeys.publishable || apiKeys.secret)) {
|
|
282
|
+
const verify = verifyToken ?? (auth ? jwtVerifier(betterAuthTokens({ auth })) : () => Promise.reject(unauthorized("This app signs no user tokens")));
|
|
283
|
+
app.route("/realtime/v1", realtimeApp({
|
|
284
|
+
deps,
|
|
285
|
+
feed,
|
|
286
|
+
upgradeWebSocket,
|
|
287
|
+
caller: apiKeyCaller({
|
|
288
|
+
keys: apiKeys,
|
|
289
|
+
verify
|
|
290
|
+
}),
|
|
291
|
+
report
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
270
294
|
}
|
|
271
295
|
app.route(basePath, api);
|
|
272
296
|
app.route(`${deps.systemPath}/meta`, metaApp);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { goTrueAlgorithm, goTrueAudience, goTrueIssuer, goTrueKeys } from "./keys.js";
|
|
2
|
+
import { cachedKeys } from "../better-auth/jwks-cache.js";
|
|
3
|
+
//#region src/gotrue/access-tokens.ts
|
|
4
|
+
/**
|
|
5
|
+
* How the tokens of the GoTrue-compatible endpoints verify, read from the auth instance: for `jwtVerifier`, such as
|
|
6
|
+
* `apiKeyCaller({ keys, verify: jwtVerifier(goTrueTokens({ auth })) })`.
|
|
7
|
+
*/
|
|
8
|
+
const goTrueTokens = ({ auth, cache }) => ({
|
|
9
|
+
keys: cachedKeys(async () => goTrueKeys(await auth.api.getJwks()), cache),
|
|
10
|
+
issuer: goTrueIssuer(auth.issuer),
|
|
11
|
+
audience: goTrueAudience,
|
|
12
|
+
algorithms: [goTrueAlgorithm]
|
|
13
|
+
});
|
|
14
|
+
//#endregion
|
|
15
|
+
export { goTrueTokens };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { hashOf } from "../better-auth/sign-up/sign-up-code.js";
|
|
2
|
+
import { GoTrueError } from "./errors.js";
|
|
3
|
+
import { generateRandomString } from "better-auth/crypto";
|
|
4
|
+
//#region src/gotrue/auth-codes.ts
|
|
5
|
+
const codeSeconds = 300;
|
|
6
|
+
const identifierOf = async (code) => `gotrue-auth-code-${await hashOf(code)}`;
|
|
7
|
+
const base64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
8
|
+
/** Whether `verifier` is the one `challenge` was made from: its SHA-256 in base64url for `s256`, itself for `plain`. */
|
|
9
|
+
const matchesChallenge = async (verifier, { challenge, method }) => method === "plain" ? verifier === challenge : base64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))) === challenge;
|
|
10
|
+
/** A new code that signs in `userId` for whoever holds the verifier of `challenge`. */
|
|
11
|
+
const issueAuthCode = async (context, userId, challenge) => {
|
|
12
|
+
const code = generateRandomString(32);
|
|
13
|
+
await context.internalAdapter.createVerificationValue({
|
|
14
|
+
identifier: await identifierOf(code),
|
|
15
|
+
value: JSON.stringify({
|
|
16
|
+
userId,
|
|
17
|
+
...challenge
|
|
18
|
+
}),
|
|
19
|
+
expiresAt: new Date(Date.now() + codeSeconds * 1e3)
|
|
20
|
+
});
|
|
21
|
+
return code;
|
|
22
|
+
};
|
|
23
|
+
const invalidGrant = () => new GoTrueError(400, "flow_state_not_found", "invalid flow state, no valid flow state found");
|
|
24
|
+
/** Takes `code`, so it works once, and answers its user when `verifier` matches its challenge. */
|
|
25
|
+
const takeAuthCode = async (context, code, verifier) => {
|
|
26
|
+
const consumed = await context.internalAdapter.consumeVerificationValue(await identifierOf(code));
|
|
27
|
+
if (!consumed || new Date(consumed.expiresAt) < /* @__PURE__ */ new Date()) throw invalidGrant();
|
|
28
|
+
const { userId, ...challenge } = JSON.parse(consumed.value);
|
|
29
|
+
if (!await matchesChallenge(verifier, challenge)) throw new GoTrueError(400, "bad_code_verifier", "code challenge does not match previously saved code verifier");
|
|
30
|
+
return userId;
|
|
31
|
+
};
|
|
32
|
+
//#endregion
|
|
33
|
+
export { issueAuthCode, matchesChallenge, takeAuthCode };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { GoTrueError } from "./errors.js";
|
|
2
|
+
import { errors, jwtVerify } from "jose";
|
|
3
|
+
//#region src/gotrue/bearer.ts
|
|
4
|
+
/**
|
|
5
|
+
* Reads the signed-in user's access token from `Authorization: Bearer`, verified with the keys, issuer and audience of `goTrueTokens`:
|
|
6
|
+
* its user and session. Without one, GoTrue's `no_authorization`; one that does not verify is `bad_jwt`.
|
|
7
|
+
*/
|
|
8
|
+
const accessClaimsOf = ({ keys, ...expected }) => async (request) => {
|
|
9
|
+
const [scheme, token] = (request.headers.get("authorization") ?? "").split(" ");
|
|
10
|
+
if (scheme?.toLowerCase() !== "bearer" || !token) throw new GoTrueError(401, "no_authorization", "This endpoint requires a valid Bearer token");
|
|
11
|
+
const { payload } = await jwtVerify(token, keys, expected).catch((error) => {
|
|
12
|
+
if (error instanceof errors.JOSEError) throw new GoTrueError(403, "bad_jwt", "invalid JWT: unable to parse or verify signature, token is invalid or expired");
|
|
13
|
+
throw error;
|
|
14
|
+
});
|
|
15
|
+
if (typeof payload.sub !== "string" || typeof payload.session_id !== "string") throw new GoTrueError(403, "bad_jwt", "invalid JWT: it names no user and session");
|
|
16
|
+
return {
|
|
17
|
+
sub: payload.sub,
|
|
18
|
+
session_id: payload.session_id
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
//#endregion
|
|
22
|
+
export { accessClaimsOf };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { effectiveSignInPolicy } from "../better-auth/sign-in-policy.js";
|
|
2
|
+
import { readSignInPolicy } from "../better-auth/policy-store.js";
|
|
3
|
+
import { checkNewAccountPassword } from "../better-auth/new-account-password.js";
|
|
4
|
+
import { createSignedUpAccount } from "../better-auth/sign-up/sign-up-account.js";
|
|
5
|
+
import { consumeSignUpTicket } from "../better-auth/sign-up/sign-up-ticket.js";
|
|
6
|
+
import { GoTrueError } from "./errors.js";
|
|
7
|
+
import { callBetterAuth, signedInToken } from "./forward.js";
|
|
8
|
+
import { hasPendingSignUp, savePendingSignUp, takePendingSignUp } from "./pending-sign-up.js";
|
|
9
|
+
import { address, readBody } from "./request-body.js";
|
|
10
|
+
import { Hono } from "hono";
|
|
11
|
+
import * as z$1 from "zod";
|
|
12
|
+
//#region src/gotrue/email-code-routes.ts
|
|
13
|
+
const metadata = z$1.record(z$1.string(), z$1.unknown()).default({});
|
|
14
|
+
const signUpBody = z$1.object({
|
|
15
|
+
email: address,
|
|
16
|
+
password: z$1.string(),
|
|
17
|
+
data: metadata
|
|
18
|
+
});
|
|
19
|
+
const otpBody = z$1.object({
|
|
20
|
+
email: address,
|
|
21
|
+
create_user: z$1.boolean().default(true),
|
|
22
|
+
data: metadata
|
|
23
|
+
});
|
|
24
|
+
const verifyBody = z$1.object({
|
|
25
|
+
email: address.optional(),
|
|
26
|
+
token: z$1.string().trim().optional(),
|
|
27
|
+
token_hash: z$1.string().optional(),
|
|
28
|
+
type: z$1.string()
|
|
29
|
+
});
|
|
30
|
+
const emailBody = z$1.object({ email: address });
|
|
31
|
+
const resendBody = z$1.object({
|
|
32
|
+
email: address,
|
|
33
|
+
type: z$1.string()
|
|
34
|
+
});
|
|
35
|
+
const signUpDisabled = () => new GoTrueError(422, "signup_disabled", "Signups not allowed for this instance");
|
|
36
|
+
const nameOf = (email, data) => {
|
|
37
|
+
const given = data.name ?? data.full_name;
|
|
38
|
+
return typeof given === "string" && given.trim() ? given.trim() : email.split("@")[0];
|
|
39
|
+
};
|
|
40
|
+
const emailCodeRoutes = ({ auth, context, sessionOf }) => {
|
|
41
|
+
const checkMetadata = (data) => {
|
|
42
|
+
if (Object.keys(data).length > 0 && !auth.userMetadata) throw new GoTrueError(400, "validation_failed", "This app keeps no user metadata: leave data out");
|
|
43
|
+
};
|
|
44
|
+
const signUpPolicy = async () => effectiveSignInPolicy((await readSignInPolicy((await context()).adapter)).policy, { mail: true });
|
|
45
|
+
const sendSignUpCode = (request, email) => callBetterAuth(auth, request, "/sign-up/send-code", { email });
|
|
46
|
+
const sendSignInCode = (request, email) => callBetterAuth(auth, request, "/email-otp/send-verification-otp", {
|
|
47
|
+
email,
|
|
48
|
+
type: "sign-in"
|
|
49
|
+
});
|
|
50
|
+
const signInWithCode = async (request, email, code) => {
|
|
51
|
+
const { json } = await callBetterAuth(auth, request, "/sign-in/email-otp", {
|
|
52
|
+
email,
|
|
53
|
+
otp: code
|
|
54
|
+
});
|
|
55
|
+
return sessionOf(signedInToken(json));
|
|
56
|
+
};
|
|
57
|
+
const signUpWithCode = async (request, email, code) => {
|
|
58
|
+
const ctx = await context();
|
|
59
|
+
if (!auth.signUpRole || !await hasPendingSignUp(ctx, email)) throw new GoTrueError(403, "otp_expired", "Token has expired or is invalid");
|
|
60
|
+
const { json } = await callBetterAuth(auth, request, "/sign-up/verify-code", {
|
|
61
|
+
email,
|
|
62
|
+
code
|
|
63
|
+
});
|
|
64
|
+
await consumeSignUpTicket(ctx, String(json.ticket));
|
|
65
|
+
const pending = await takePendingSignUp(ctx, email);
|
|
66
|
+
if (!pending) throw new GoTrueError(403, "otp_expired", "Token has expired or is invalid");
|
|
67
|
+
const { passwordHash, metadata } = pending;
|
|
68
|
+
const user = await createSignedUpAccount(ctx, {
|
|
69
|
+
email,
|
|
70
|
+
name: nameOf(email, metadata),
|
|
71
|
+
role: auth.signUpRole,
|
|
72
|
+
...passwordHash !== void 0 && { passwordHash },
|
|
73
|
+
...Object.keys(metadata).length > 0 && { metadata }
|
|
74
|
+
});
|
|
75
|
+
return sessionOf((await ctx.internalAdapter.createSession(user.id)).token);
|
|
76
|
+
};
|
|
77
|
+
return new Hono().post("/signup", async (c) => {
|
|
78
|
+
const { email, password, data } = await readBody(c.req.raw, signUpBody);
|
|
79
|
+
if (!auth.signUp) throw signUpDisabled();
|
|
80
|
+
checkMetadata(data);
|
|
81
|
+
checkNewAccountPassword(await signUpPolicy(), password);
|
|
82
|
+
await sendSignUpCode(c.req.raw, email);
|
|
83
|
+
await savePendingSignUp(await context(), email, {
|
|
84
|
+
passwordHash: await (await context()).password.hash(password),
|
|
85
|
+
metadata: data
|
|
86
|
+
});
|
|
87
|
+
return c.json({});
|
|
88
|
+
}).post("/otp", async (c) => {
|
|
89
|
+
const { email, create_user, data } = await readBody(c.req.raw, otpBody);
|
|
90
|
+
if (!(create_user && auth.signUp && !await (await context()).internalAdapter.findUserByEmail(email))) {
|
|
91
|
+
await sendSignInCode(c.req.raw, email);
|
|
92
|
+
return c.json({});
|
|
93
|
+
}
|
|
94
|
+
checkMetadata(data);
|
|
95
|
+
checkNewAccountPassword(await signUpPolicy(), void 0);
|
|
96
|
+
await sendSignUpCode(c.req.raw, email);
|
|
97
|
+
await savePendingSignUp(await context(), email, { metadata: data });
|
|
98
|
+
return c.json({});
|
|
99
|
+
}).post("/verify", async (c) => {
|
|
100
|
+
const { email, token, token_hash, type } = await readBody(c.req.raw, verifyBody);
|
|
101
|
+
if (token_hash !== void 0 || email === void 0 || !token) throw new GoTrueError(400, "validation_failed", "Send the email address and the code mailed to it: links are not offered");
|
|
102
|
+
if (type === "signup") return c.json(await signUpWithCode(c.req.raw, email, token));
|
|
103
|
+
if (type === "recovery") return c.json(await signInWithCode(c.req.raw, email, token));
|
|
104
|
+
if (type === "email" || type === "magiclink") {
|
|
105
|
+
const known = await (await context()).internalAdapter.findUserByEmail(email);
|
|
106
|
+
return c.json(await (known ? signInWithCode(c.req.raw, email, token) : signUpWithCode(c.req.raw, email, token)));
|
|
107
|
+
}
|
|
108
|
+
throw new GoTrueError(400, "validation_failed", `Verify type ${type} is not offered: use email, signup or recovery`);
|
|
109
|
+
}).post("/recover", async (c) => {
|
|
110
|
+
const { email } = await readBody(c.req.raw, emailBody);
|
|
111
|
+
await sendSignInCode(c.req.raw, email);
|
|
112
|
+
return c.json({});
|
|
113
|
+
}).post("/resend", async (c) => {
|
|
114
|
+
const { email, type } = await readBody(c.req.raw, resendBody);
|
|
115
|
+
if (type !== "signup") throw new GoTrueError(400, "validation_failed", `Resend type ${type} is not offered: use signup`);
|
|
116
|
+
if (auth.signUp && await hasPendingSignUp(await context(), email)) await sendSignUpCode(c.req.raw, email);
|
|
117
|
+
return c.json({});
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
//#endregion
|
|
121
|
+
export { emailCodeRoutes };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { APIError } from "better-auth/api";
|
|
2
|
+
//#region src/gotrue/errors.ts
|
|
3
|
+
/** A refusal as GoTrue words it: an HTTP status, one of its error codes, and a message for people. */
|
|
4
|
+
var GoTrueError = class extends Error {
|
|
5
|
+
status;
|
|
6
|
+
errorCode;
|
|
7
|
+
constructor(status, errorCode, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.errorCode = errorCode;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
/** The JSON GoTrue answers a refusal with; supabase-js reads `error_code` and `msg` from it. */
|
|
14
|
+
const errorBody = (error) => ({
|
|
15
|
+
code: error.status,
|
|
16
|
+
error_code: error.errorCode,
|
|
17
|
+
msg: error.message
|
|
18
|
+
});
|
|
19
|
+
const invalidCode = [
|
|
20
|
+
403,
|
|
21
|
+
"otp_expired",
|
|
22
|
+
"Token has expired or is invalid"
|
|
23
|
+
];
|
|
24
|
+
const known = {
|
|
25
|
+
INVALID_EMAIL_OR_PASSWORD: [
|
|
26
|
+
400,
|
|
27
|
+
"invalid_credentials",
|
|
28
|
+
"Invalid login credentials"
|
|
29
|
+
],
|
|
30
|
+
INVALID_OTP: invalidCode,
|
|
31
|
+
OTP_EXPIRED: invalidCode,
|
|
32
|
+
TOO_MANY_ATTEMPTS: invalidCode,
|
|
33
|
+
SIGN_UP_CODE_INVALID: invalidCode,
|
|
34
|
+
SIGN_UP_TICKET_INVALID: invalidCode,
|
|
35
|
+
BANNED_USER: [403, "user_banned"],
|
|
36
|
+
NO_ADMIN_YET: [422, "signup_disabled"],
|
|
37
|
+
PASSWORD_LENGTH: [422, "weak_password"],
|
|
38
|
+
PASSWORD_REQUIRED: [422, "weak_password"]
|
|
39
|
+
};
|
|
40
|
+
/** A refusal of Better Auth, by its status and body, as GoTrue's: a known code becomes GoTrue's, any other its own in lower case. */
|
|
41
|
+
const fromBetterAuth = (status, body) => {
|
|
42
|
+
const code = typeof body?.code === "string" ? body.code : void 0;
|
|
43
|
+
const message = typeof body?.message === "string" ? body.message : "The request was refused";
|
|
44
|
+
if (status === 429) return new GoTrueError(429, "over_request_rate_limit", message);
|
|
45
|
+
const mapped = code === void 0 ? void 0 : known[code];
|
|
46
|
+
if (mapped) return new GoTrueError(mapped[0], mapped[1], mapped[2] ?? message);
|
|
47
|
+
return new GoTrueError(status, code?.toLowerCase() ?? (status >= 500 ? "unexpected_failure" : "bad_request"), message);
|
|
48
|
+
};
|
|
49
|
+
/** A Better Auth `APIError` as GoTrue's refusal; any other error as it is. */
|
|
50
|
+
const asGoTrueError = (error) => error instanceof APIError ? fromBetterAuth(error.statusCode, error.body) : error;
|
|
51
|
+
//#endregion
|
|
52
|
+
export { GoTrueError, asGoTrueError, errorBody, fromBetterAuth };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { authBasePath } from "../better-auth/auth-base-path.js";
|
|
2
|
+
import { GoTrueError, fromBetterAuth } from "./errors.js";
|
|
3
|
+
//#region src/gotrue/forward.ts
|
|
4
|
+
const withheld = [
|
|
5
|
+
"cookie",
|
|
6
|
+
"authorization",
|
|
7
|
+
"origin",
|
|
8
|
+
"referer",
|
|
9
|
+
"content-length",
|
|
10
|
+
"content-type"
|
|
11
|
+
];
|
|
12
|
+
const parsed = (text) => text ? JSON.parse(text) : void 0;
|
|
13
|
+
/**
|
|
14
|
+
* Posts `body` to Better Auth's endpoint at `path`, such as `/sign-in/email`, for the client's `request`: with its
|
|
15
|
+
* other headers, so the rate limits and the session made count the client's address and agent. Answers the endpoint's
|
|
16
|
+
* JSON and headers; a refusal is thrown as GoTrue's.
|
|
17
|
+
*/
|
|
18
|
+
const callBetterAuth = async (auth, request, path, body) => {
|
|
19
|
+
const headers = new Headers(request.headers);
|
|
20
|
+
for (const name of withheld) headers.delete(name);
|
|
21
|
+
const origin = new URL(request.url).origin;
|
|
22
|
+
headers.set("content-type", "application/json");
|
|
23
|
+
headers.set("origin", origin);
|
|
24
|
+
const response = await auth.handler(new Request(`${origin}${authBasePath}${path}`, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers,
|
|
27
|
+
body: JSON.stringify(body)
|
|
28
|
+
}));
|
|
29
|
+
const json = parsed(await response.text());
|
|
30
|
+
if (!response.ok) throw fromBetterAuth(response.status, json);
|
|
31
|
+
return {
|
|
32
|
+
json: json ?? {},
|
|
33
|
+
headers: response.headers
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The session token a Better Auth sign-in answers. Someone with two-factor authentication gets none yet, only the
|
|
38
|
+
* second step, which is not offered here: GoTrue's `insufficient_aal`.
|
|
39
|
+
*/
|
|
40
|
+
const signedInToken = (json) => {
|
|
41
|
+
if (typeof json.token !== "string") throw new GoTrueError(403, "insufficient_aal", "This account signs in with a second factor, which is not offered here");
|
|
42
|
+
return json.token;
|
|
43
|
+
};
|
|
44
|
+
//#endregion
|
|
45
|
+
export { callBetterAuth, signedInToken };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/gotrue/keys.ts
|
|
2
|
+
/** Where the GoTrue-compatible endpoints are served, below the app's origin. */
|
|
3
|
+
const goTruePath = "/auth/v1";
|
|
4
|
+
/** The key pair the JWT plugin keeps beside its EdDSA one for these tokens; it makes it on the first token. */
|
|
5
|
+
const goTrueKeyPair = { alg: "ES256" };
|
|
6
|
+
const goTrueAlgorithm = goTrueKeyPair.alg;
|
|
7
|
+
/** The audience of every such token: the database role a signed-in user's requests run as. */
|
|
8
|
+
const goTrueAudience = "authenticated";
|
|
9
|
+
/** The issuer of the tokens of the app at `baseURL`. */
|
|
10
|
+
const goTrueIssuer = (baseURL) => `${baseURL.replace(/\/+$/, "")}${goTruePath}`;
|
|
11
|
+
/** The keys of the JWT plugin's key set that sign these tokens. */
|
|
12
|
+
const goTrueKeys = (set) => ({ keys: set.keys.filter((key) => key.alg === goTrueAlgorithm) });
|
|
13
|
+
//#endregion
|
|
14
|
+
export { goTrueAlgorithm, goTrueAudience, goTrueIssuer, goTrueKeyPair, goTrueKeys, goTruePath };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//#region src/gotrue/pending-sign-up.ts
|
|
2
|
+
const pendingSeconds = 3600;
|
|
3
|
+
const identifierOf = (email) => `gotrue-sign-up-${email}`;
|
|
4
|
+
/** Keeps what `email` signs up with, replacing what an earlier sign-up with it gave. */
|
|
5
|
+
const savePendingSignUp = async (context, email, pending) => {
|
|
6
|
+
const identifier = identifierOf(email);
|
|
7
|
+
await context.internalAdapter.deleteVerificationByIdentifier(identifier);
|
|
8
|
+
await context.internalAdapter.createVerificationValue({
|
|
9
|
+
identifier,
|
|
10
|
+
value: JSON.stringify(pending),
|
|
11
|
+
expiresAt: new Date(Date.now() + pendingSeconds * 1e3)
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
/** Whether a sign-up with `email` waits for its code. */
|
|
15
|
+
const hasPendingSignUp = async (context, email) => {
|
|
16
|
+
const found = await context.internalAdapter.findVerificationValue(identifierOf(email));
|
|
17
|
+
return found !== null && new Date(found.expiresAt) > /* @__PURE__ */ new Date();
|
|
18
|
+
};
|
|
19
|
+
/** Takes what `email` signed up with, so it is used once; `undefined` when nothing waits, or it waited too long. */
|
|
20
|
+
const takePendingSignUp = async (context, email) => {
|
|
21
|
+
const consumed = await context.internalAdapter.consumeVerificationValue(identifierOf(email));
|
|
22
|
+
if (!consumed || new Date(consumed.expiresAt) < /* @__PURE__ */ new Date()) return void 0;
|
|
23
|
+
return JSON.parse(consumed.value);
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { hasPendingSignUp, savePendingSignUp, takePendingSignUp };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { goTruePath } from "./keys.js";
|
|
2
|
+
import { GoTrueError } from "./errors.js";
|
|
3
|
+
import { callBetterAuth } from "./forward.js";
|
|
4
|
+
import { issueAuthCode } from "./auth-codes.js";
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
//#region src/gotrue/provider-routes.ts
|
|
7
|
+
const challengeOf = (query) => {
|
|
8
|
+
const challenge = query.code_challenge;
|
|
9
|
+
if (!challenge) return void 0;
|
|
10
|
+
const method = (query.code_challenge_method ?? "s256").toLowerCase();
|
|
11
|
+
if (method !== "s256" && method !== "plain") throw new GoTrueError(400, "validation_failed", "code_challenge_method must be s256 or plain");
|
|
12
|
+
return {
|
|
13
|
+
challenge,
|
|
14
|
+
method
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
const withParams = (url, params, where) => {
|
|
18
|
+
const target = new URL(url);
|
|
19
|
+
const values = new URLSearchParams(where === "hash" ? target.hash.slice(1) : target.search);
|
|
20
|
+
for (const [name, value] of Object.entries(params)) values.set(name, value);
|
|
21
|
+
target[where] = values.toString();
|
|
22
|
+
return target.toString();
|
|
23
|
+
};
|
|
24
|
+
const providerRoutes = ({ auth, context, sessionOf }) => {
|
|
25
|
+
const destination = async (requested, request) => {
|
|
26
|
+
const own = new URL(request.url).origin;
|
|
27
|
+
if (requested && URL.canParse(requested) && (await context()).isTrustedOrigin(requested)) return requested;
|
|
28
|
+
return own;
|
|
29
|
+
};
|
|
30
|
+
return new Hono().get("/authorize", async (c) => {
|
|
31
|
+
const query = c.req.query();
|
|
32
|
+
const provider = query.provider ?? "";
|
|
33
|
+
if (!auth.socialProviders.includes(provider)) throw new GoTrueError(400, "validation_failed", `Unsupported provider: ${provider || "none"} is not one of this app's`);
|
|
34
|
+
const challenge = challengeOf(query);
|
|
35
|
+
const callback = new URL(`${goTruePath}/callback`, c.req.url);
|
|
36
|
+
callback.searchParams.set("redirect_to", await destination(query.redirect_to, c.req.raw));
|
|
37
|
+
if (challenge) {
|
|
38
|
+
callback.searchParams.set("code_challenge", challenge.challenge);
|
|
39
|
+
callback.searchParams.set("code_challenge_method", challenge.method);
|
|
40
|
+
}
|
|
41
|
+
const { json, headers } = await callBetterAuth(auth, c.req.raw, "/sign-in/social", {
|
|
42
|
+
provider,
|
|
43
|
+
callbackURL: callback.toString(),
|
|
44
|
+
errorCallbackURL: callback.toString(),
|
|
45
|
+
disableRedirect: true,
|
|
46
|
+
...query.scopes && { scopes: query.scopes.split(/[\s,]+/).filter(Boolean) }
|
|
47
|
+
});
|
|
48
|
+
const response = c.redirect(String(json.url), 302);
|
|
49
|
+
for (const cookie of headers.getSetCookie()) response.headers.append("set-cookie", cookie);
|
|
50
|
+
return response;
|
|
51
|
+
}).get("/callback", async (c) => {
|
|
52
|
+
const query = c.req.query();
|
|
53
|
+
const target = await destination(query.redirect_to, c.req.raw);
|
|
54
|
+
const challenge = challengeOf(query);
|
|
55
|
+
const where = challenge ? "search" : "hash";
|
|
56
|
+
const signedIn = !query.error && await auth.api.getSession({ headers: c.req.raw.headers });
|
|
57
|
+
if (!signedIn) {
|
|
58
|
+
const error = query.error ?? "access_denied";
|
|
59
|
+
return c.redirect(withParams(target, {
|
|
60
|
+
error,
|
|
61
|
+
error_code: error,
|
|
62
|
+
error_description: query.error_description ?? "The sign-in with the provider did not complete"
|
|
63
|
+
}, where), 302);
|
|
64
|
+
}
|
|
65
|
+
const ctx = await context();
|
|
66
|
+
if (challenge) return c.redirect(withParams(target, { code: await issueAuthCode(ctx, signedIn.user.id, challenge) }, "search"), 302);
|
|
67
|
+
const session = await sessionOf((await ctx.internalAdapter.createSession(signedIn.user.id)).token);
|
|
68
|
+
const fragment = {
|
|
69
|
+
access_token: session.access_token,
|
|
70
|
+
token_type: "bearer",
|
|
71
|
+
expires_in: String(session.expires_in),
|
|
72
|
+
expires_at: String(session.expires_at),
|
|
73
|
+
refresh_token: session.refresh_token
|
|
74
|
+
};
|
|
75
|
+
return c.redirect(withParams(target, fragment, "hash"), 302);
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
//#endregion
|
|
79
|
+
export { providerRoutes };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { GoTrueError } from "./errors.js";
|
|
2
|
+
import * as z$1 from "zod";
|
|
3
|
+
//#region src/gotrue/request-body.ts
|
|
4
|
+
/** An address as GoTrue takes it: trimmed and in lower case. */
|
|
5
|
+
const address = z$1.string().trim().toLowerCase().pipe(z$1.email());
|
|
6
|
+
const parsedJson = (text) => {
|
|
7
|
+
try {
|
|
8
|
+
return text ? JSON.parse(text) : {};
|
|
9
|
+
} catch (error) {
|
|
10
|
+
if (error instanceof SyntaxError) throw new GoTrueError(400, "bad_json", "Could not parse request body as JSON");
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
/** The request's JSON body as `schema` reads it; anything else is GoTrue's `validation_failed`, naming the field. */
|
|
15
|
+
const readBody = async (request, schema) => {
|
|
16
|
+
const result = schema.safeParse(parsedJson(await request.text()));
|
|
17
|
+
if (result.success) return result.data;
|
|
18
|
+
const [issue] = result.error.issues;
|
|
19
|
+
throw new GoTrueError(400, "validation_failed", issue ? `${issue.path.join(".") || "body"}: ${issue.message}` : "The request body is not valid");
|
|
20
|
+
};
|
|
21
|
+
//#endregion
|
|
22
|
+
export { address, readBody };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { goTrueKeys } from "./keys.js";
|
|
2
|
+
import { goTrueDeps } from "./deps.js";
|
|
3
|
+
import { GoTrueError, asGoTrueError, errorBody } from "./errors.js";
|
|
4
|
+
import { emailCodeRoutes } from "./email-code-routes.js";
|
|
5
|
+
import { providerRoutes } from "./provider-routes.js";
|
|
6
|
+
import { tokenRoute } from "./token-route.js";
|
|
7
|
+
import { userRoutes } from "./user-routes.js";
|
|
8
|
+
import { Hono } from "hono";
|
|
9
|
+
//#region src/gotrue/routes.ts
|
|
10
|
+
/**
|
|
11
|
+
* GoTrue's endpoints over Better Auth, for `supabase.auth` and `@supabase/ssr`, to mount at `goTruePath`: sign-in by
|
|
12
|
+
* password, emailed code or a sign-in provider, sign-up with an emailed code, refresh, the user and sign-out, and the
|
|
13
|
+
* keys of the access tokens at `/.well-known/jwks.json`. Every step is Better Auth's own, with its rate limits and the
|
|
14
|
+
* app's sign-in policy; refusals are GoTrue's JSON.
|
|
15
|
+
*/
|
|
16
|
+
const goTrueRoutes = (auth) => {
|
|
17
|
+
const deps = goTrueDeps(auth);
|
|
18
|
+
const app = new Hono();
|
|
19
|
+
app.onError((error, c) => {
|
|
20
|
+
const refusal = asGoTrueError(error);
|
|
21
|
+
if (!(refusal instanceof GoTrueError)) throw error;
|
|
22
|
+
return c.json(errorBody(refusal), refusal.status);
|
|
23
|
+
});
|
|
24
|
+
app.get("/.well-known/jwks.json", async (c) => c.json(goTrueKeys(await auth.api.getJwks())));
|
|
25
|
+
app.route("/", tokenRoute(deps));
|
|
26
|
+
app.route("/", emailCodeRoutes(deps));
|
|
27
|
+
app.route("/", userRoutes(deps));
|
|
28
|
+
app.route("/", providerRoutes(deps));
|
|
29
|
+
return app;
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
export { goTrueRoutes };
|