@pithy-sh/auth 0.1.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/LICENSE +21 -0
- package/README.md +46 -0
- package/docs/apple-signin.md +139 -0
- package/docs/facebook-oauth.md +92 -0
- package/docs/github-oauth.md +99 -0
- package/docs/google-oauth.md +118 -0
- package/package.json +58 -0
- package/pithy.manifest.json +108 -0
- package/src/admin/users.ts +357 -0
- package/src/audit/actions.ts +71 -0
- package/src/audit/emit.ts +223 -0
- package/src/capability.ts +300 -0
- package/src/client/api.ts +501 -0
- package/src/client/projection.ts +55 -0
- package/src/cloudflare-test.d.ts +16 -0
- package/src/data/betterAuth.ts +210 -0
- package/src/data/device.ts +57 -0
- package/src/data/kitFields.ts +69 -0
- package/src/data/rotatedToken.ts +40 -0
- package/src/data/tables.ts +38 -0
- package/src/device/registry.ts +139 -0
- package/src/email/send.ts +67 -0
- package/src/http/adminRoutes.ts +368 -0
- package/src/http/baseUrl.ts +109 -0
- package/src/http/csrf.ts +98 -0
- package/src/http/devLoginRoute.ts +159 -0
- package/src/http/errors.ts +70 -0
- package/src/http/guards.ts +158 -0
- package/src/http/middleware.ts +67 -0
- package/src/http/rateLimit.ts +36 -0
- package/src/http/resolve.ts +152 -0
- package/src/http/responses.ts +199 -0
- package/src/http/routes.ts +325 -0
- package/src/http/schemas.ts +118 -0
- package/src/http/views.ts +93 -0
- package/src/i18n/errorCopy.es.ts +35 -0
- package/src/i18n/errorCopy.ts +99 -0
- package/src/index.ts +24 -0
- package/src/instance/auth.ts +309 -0
- package/src/instance/plugins.ts +172 -0
- package/src/instance/providers.ts +185 -0
- package/src/instance/secrets.ts +197 -0
- package/src/migrations/0001_init.ts +229 -0
- package/src/migrations/pluginTables.ts +334 -0
- package/src/seeds/devSession.ts +286 -0
- package/src/seeds/example.ts +48 -0
- package/src/test-utils/liveApp.ts +338 -0
- package/src/token/rotation.ts +104 -0
- package/src/version.generated.ts +16 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { i18n } from "@better-auth/i18n";
|
|
5
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import type { BetterAuthPlugin } from "better-auth";
|
|
7
|
+
import { bearer } from "better-auth/plugins/bearer";
|
|
8
|
+
import { emailOTP } from "better-auth/plugins/email-otp";
|
|
9
|
+
import { jwt } from "better-auth/plugins/jwt";
|
|
10
|
+
import { magicLink } from "better-auth/plugins/magic-link";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { authErrorTranslations } from "../i18n/errorCopy";
|
|
13
|
+
import type { SendAuthEmail } from "./auth";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The four Better Auth plugins the kit composes for itself, and the reason they are fixed.
|
|
17
|
+
*
|
|
18
|
+
* `magic-link` and `email-otp` **are** the sign-in this product promises — passwordless, no
|
|
19
|
+
* `emailAndPassword` anywhere. `jwt` mints the JWKS the control-plane seam verifies against and
|
|
20
|
+
* `bearer` is how a mobile client presents its credential. Every one of them is depended on by code
|
|
21
|
+
* outside this package, so an adopter who removed or redefined one would break a contract they cannot
|
|
22
|
+
* see from their config. The adopter's list is therefore **additive**: it extends this set, never
|
|
23
|
+
* replaces a member of it.
|
|
24
|
+
*/
|
|
25
|
+
export const KIT_PLUGIN_IDS = ["i18n", "bearer", "jwt", "magic-link", "email-otp"] as const;
|
|
26
|
+
|
|
27
|
+
/** What the kit's own plugins need to be constructed. The subset of `AuthInstanceDeps` they read. */
|
|
28
|
+
export interface KitPluginDeps {
|
|
29
|
+
/** Magic-link / OTP token lifetime in seconds. */
|
|
30
|
+
verificationExpiresIn: number;
|
|
31
|
+
/** OTP length (digits). */
|
|
32
|
+
otpLength: number;
|
|
33
|
+
/** When true, sign-in never provisions a new user (existing accounts only). */
|
|
34
|
+
disableSignUp: boolean;
|
|
35
|
+
/** Deliver a magic link or OTP. Enqueues an email job; never sends inline. */
|
|
36
|
+
sendEmail: SendAuthEmail;
|
|
37
|
+
/**
|
|
38
|
+
* The catalog locale this request negotiated, or `null`/absent when nothing did.
|
|
39
|
+
*
|
|
40
|
+
* Read from `c.var.locale` where the instance is built, which is per request — so this is the locale
|
|
41
|
+
* the *project* resolved through its own configured chain, never a second negotiation of Better
|
|
42
|
+
* Auth's own. Without that a reader who chose Spanish with `?lang=es` would get Spanish screens and
|
|
43
|
+
* English refusals on the same page, because the two chains ask different signals in different
|
|
44
|
+
* orders (#452).
|
|
45
|
+
*
|
|
46
|
+
* `null` is the ordinary state of a project that never composed `i18n`, and it means English — which
|
|
47
|
+
* is what the plugin answers with anyway, so its absence is not a special case.
|
|
48
|
+
*/
|
|
49
|
+
locale?: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The kit's four, as a **tuple** rather than an array. Better Auth infers a composed instance's whole
|
|
54
|
+
* `$Infer` surface from the element types of its `plugins` list, so widening this to
|
|
55
|
+
* `BetterAuthPlugin[]` here would erase the adopter's plugin types one line later, where `makeAuth`
|
|
56
|
+
* spreads the two lists together.
|
|
57
|
+
*/
|
|
58
|
+
export type KitPlugins = [
|
|
59
|
+
// Widened where the other four are not, and it costs nothing: the translator contributes no
|
|
60
|
+
// endpoints and no `$Infer` surface, so there is no adopter-visible type to erase. Its own return
|
|
61
|
+
// type names an internal `MiddlewareOptions` that cannot be named from here, which is a TS4058 on
|
|
62
|
+
// `makeAuth`'s declaration emit rather than anything about the composition.
|
|
63
|
+
BetterAuthPlugin,
|
|
64
|
+
ReturnType<typeof bearer>,
|
|
65
|
+
ReturnType<typeof jwt>,
|
|
66
|
+
ReturnType<typeof magicLink>,
|
|
67
|
+
ReturnType<typeof emailOTP>,
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the kit's own plugins. **One definition, two readers**: `makeAuth` composes these into the
|
|
72
|
+
* live instance, and the migration builder composes the same four into the baseline schema it diffs an
|
|
73
|
+
* adopter's plugins against. Two copies of this list would be drift that no test could see — the
|
|
74
|
+
* baseline would claim a table the instance never created, or miss one it did.
|
|
75
|
+
*/
|
|
76
|
+
export function kitPlugins(deps: KitPluginDeps): KitPlugins {
|
|
77
|
+
return [
|
|
78
|
+
/**
|
|
79
|
+
* Better Auth's own refusals, in the reader's language (#452).
|
|
80
|
+
*
|
|
81
|
+
* **First in the list deliberately.** It works by wrapping the error rendering of the plugins
|
|
82
|
+
* registered around it, so anything composed before it answers in English regardless.
|
|
83
|
+
*
|
|
84
|
+
* `getLocale` is the whole of the integration: one chain, the project's, resolved before this
|
|
85
|
+
* instance was built. The plugin's own `header`/`cookie`/`session` strategies are deliberately
|
|
86
|
+
* unused — each is a second negotiation, and two chains over one page is the bug where the screens
|
|
87
|
+
* and the errors disagree about who is reading.
|
|
88
|
+
*/
|
|
89
|
+
i18n({
|
|
90
|
+
translations: authErrorTranslations(),
|
|
91
|
+
detection: ["callback"],
|
|
92
|
+
getLocale: () => deps.locale ?? null,
|
|
93
|
+
}),
|
|
94
|
+
bearer(),
|
|
95
|
+
jwt({ schema: { jwks: { modelName: "pithyAuthJwks" } } }),
|
|
96
|
+
magicLink({
|
|
97
|
+
expiresIn: deps.verificationExpiresIn,
|
|
98
|
+
disableSignUp: deps.disableSignUp,
|
|
99
|
+
sendMagicLink: async ({ email, url, token }) => {
|
|
100
|
+
await deps.sendEmail({ to: email, template: "magicLink", token, url });
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
emailOTP({
|
|
104
|
+
otpLength: deps.otpLength,
|
|
105
|
+
expiresIn: deps.verificationExpiresIn,
|
|
106
|
+
disableSignUp: deps.disableSignUp,
|
|
107
|
+
sendVerificationOTP: async ({ email, otp, type }) => {
|
|
108
|
+
if (type !== "sign-in") return;
|
|
109
|
+
await deps.sendEmail({ to: email, template: "otp", code: otp });
|
|
110
|
+
},
|
|
111
|
+
}),
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Is this value shaped like a Better Auth plugin — an object carrying a non-empty string `id`? */
|
|
116
|
+
function isBetterAuthPlugin(value: unknown): value is BetterAuthPlugin {
|
|
117
|
+
if (typeof value !== "object" || value === null) return false;
|
|
118
|
+
const id = (value as { id?: unknown }).id;
|
|
119
|
+
return typeof id === "string" && id.length > 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* One additional Better Auth plugin, validated at the config boundary.
|
|
124
|
+
*
|
|
125
|
+
* `z.custom` rather than a `z.object`, and deliberately: a plugin is a live object carrying endpoints,
|
|
126
|
+
* hooks and an `init` closure, and an object schema would hand back a **copy** — a different object from
|
|
127
|
+
* the one Better Auth was given, with whatever the schema did not name quietly dropped. What can be
|
|
128
|
+
* checked without rebuilding it is checked: it is an object, and it has the `id` every other rule here
|
|
129
|
+
* (reservation, duplication, migration keys, `pithy doctor`) is written in terms of.
|
|
130
|
+
*/
|
|
131
|
+
export const AuthPlugin = z
|
|
132
|
+
.custom<BetterAuthPlugin>(isBetterAuthPlugin, {
|
|
133
|
+
message: "Expected a Better Auth plugin — an object with a non-empty string `id`.",
|
|
134
|
+
})
|
|
135
|
+
.describe(
|
|
136
|
+
"An additional Better Auth plugin, e.g. `organization()`, `passkey()`, `twoFactor()`. Added to the set the kit composes, never in place of one.",
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Refuse a plugin list that is not purely additive, naming the offending plugin.
|
|
141
|
+
*
|
|
142
|
+
* Two ways it can fail, and both name a single id because that is what the adopter has to go delete:
|
|
143
|
+
* a plugin whose id is one of {@link KIT_PLUGIN_IDS} would sit beside the kit's own copy (Better Auth
|
|
144
|
+
* merges endpoints by id — the later registration silently wins, so this is a redefinition even when it
|
|
145
|
+
* reads like an addition), and two plugins sharing an id do the same to each other.
|
|
146
|
+
*
|
|
147
|
+
* **The `message` stays short on purpose.** `auth()` runs while `pithy.config.ts` is being imported, so
|
|
148
|
+
* the CLI catches this through `classifyConfigLoadFailure`, which prints the cause's message and nothing
|
|
149
|
+
* else — and drops it entirely past 160 characters (`safeReason`). A refusal whose whole content is in
|
|
150
|
+
* its `action` reaches the adopter as "the config threw while loading", which names nothing.
|
|
151
|
+
*/
|
|
152
|
+
export function assertAdditivePlugins(plugins: readonly BetterAuthPlugin[]): void {
|
|
153
|
+
const reserved: readonly string[] = KIT_PLUGIN_IDS;
|
|
154
|
+
const seen = new Set<string>();
|
|
155
|
+
for (const plugin of plugins) {
|
|
156
|
+
if (reserved.includes(plugin.id)) {
|
|
157
|
+
throw new ValidationError({
|
|
158
|
+
message: `The auth capability already composes the Better Auth "${plugin.id}" plugin.`,
|
|
159
|
+
action: `Remove ${plugin.id}() from auth({ plugins: [...] }). The four the kit composes (${KIT_PLUGIN_IDS.join(", ")}) are the sign-in this product promises and what the control-plane seam verifies against; your list adds to them.`,
|
|
160
|
+
detail: `reserved plugin id "${plugin.id}" supplied through AuthConfig.plugins`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (seen.has(plugin.id)) {
|
|
164
|
+
throw new ValidationError({
|
|
165
|
+
message: `The Better Auth "${plugin.id}" plugin is listed twice in auth({ plugins: [...] }).`,
|
|
166
|
+
action: `Keep one ${plugin.id}() in auth({ plugins: [...] }) — the second registration would silently win over the first.`,
|
|
167
|
+
detail: `duplicate plugin id "${plugin.id}" supplied through AuthConfig.plugins`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
seen.add(plugin.id);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import type {
|
|
7
|
+
AppleOAuthCredentials,
|
|
8
|
+
FacebookOAuthCredentials,
|
|
9
|
+
GithubOAuthCredentials,
|
|
10
|
+
GoogleOAuthCredentials,
|
|
11
|
+
} from "./secrets";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What one social provider *is*, for one instance — three states, carried on the value (#381).
|
|
15
|
+
*
|
|
16
|
+
* ## Why three
|
|
17
|
+
*
|
|
18
|
+
* A provider used to be `Credentials | undefined`, and that shape had room for two facts and needed
|
|
19
|
+
* room for three. `undefined` meant "the adopter did not enable this"; there was no way to say "the
|
|
20
|
+
* adopter enabled it and its credential would not read", so that third fact was expressed by the only
|
|
21
|
+
* means left — the resolution rejecting, inside a `Promise.all` that also carried the session secret.
|
|
22
|
+
* One unreadable `auth-github-credentials` therefore took down magic link and OTP for every user,
|
|
23
|
+
* because a contributor to sign-in was wired as a precondition of it.
|
|
24
|
+
*
|
|
25
|
+
* The session secret and the D1 binding really are preconditions: without either there is no sign-in of
|
|
26
|
+
* any kind, and they keep failing the whole instance. A provider is one method among several.
|
|
27
|
+
*
|
|
28
|
+
* ## Why on the value
|
|
29
|
+
*
|
|
30
|
+
* This is #350's shape, applied to a provider rather than an aggregate. The credentials live *behind*
|
|
31
|
+
* the healthy member, so a call site reaches `clientId` only by narrowing on `state`, and one that
|
|
32
|
+
* forgets the sick case does not silently build a provider out of nothing — it does not compile. A flag
|
|
33
|
+
* beside an optional credential would be the same information and the opposite property.
|
|
34
|
+
*
|
|
35
|
+
* ## Why `unresolvable` carries nothing
|
|
36
|
+
*
|
|
37
|
+
* {@link resolveProvider}'s `catch` takes no binding, so nothing derived from the throw is in reach to
|
|
38
|
+
* put here. That is deliberate rather than incidental: what a secret resolution throws is a
|
|
39
|
+
* `SecretNotFoundError` whose `message` names `auth-github-credentials`, or a schema failure whose
|
|
40
|
+
* issues quote what was stored. Neither may reach a browser. Everything the refusal needs — *which*
|
|
41
|
+
* provider — is known at the call site from the key it was resolved under, so it never has to be
|
|
42
|
+
* recovered from an error.
|
|
43
|
+
*/
|
|
44
|
+
export type ResolvedProvider<Credentials> =
|
|
45
|
+
| { readonly state: "disabled" }
|
|
46
|
+
| { readonly state: "ready"; readonly credentials: Credentials }
|
|
47
|
+
| { readonly state: "unresolvable" };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The one `disabled` value, so the state has one spelling.
|
|
51
|
+
*
|
|
52
|
+
* Typed as the bare member rather than `ResolvedProvider<never>`: it is assignable to every
|
|
53
|
+
* `ResolvedProvider<C>` either way, and this way nothing can read a `credentials` off it by widening.
|
|
54
|
+
*/
|
|
55
|
+
export const PROVIDER_DISABLED: { readonly state: "disabled" } = { state: "disabled" };
|
|
56
|
+
|
|
57
|
+
/** The social providers the kit composes. Ordered as they are declared in config. */
|
|
58
|
+
export const SOCIAL_PROVIDER_IDS = ["google", "apple", "facebook", "github"] as const;
|
|
59
|
+
|
|
60
|
+
/** One of the kit's social providers. */
|
|
61
|
+
export type SocialProviderId = (typeof SOCIAL_PROVIDER_IDS)[number];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every provider's state for one instance.
|
|
65
|
+
*
|
|
66
|
+
* A record rather than four loose fields, because two things read it — the `socialProviders` block the
|
|
67
|
+
* instance is built from, and the refusal that answers a caller who asked for one that is not there —
|
|
68
|
+
* and they must be reading the same four values or they will disagree about what is available.
|
|
69
|
+
*/
|
|
70
|
+
export interface ResolvedProviders {
|
|
71
|
+
/** Google OAuth: disabled, ready with its credential pair, or declared and unreadable. */
|
|
72
|
+
google: ResolvedProvider<GoogleOAuthCredentials>;
|
|
73
|
+
/** Apple Sign-In: disabled, ready with its credential set, or declared and unreadable. */
|
|
74
|
+
apple: ResolvedProvider<AppleOAuthCredentials>;
|
|
75
|
+
/** Facebook Login: disabled, ready with its credential pair, or declared and unreadable. */
|
|
76
|
+
facebook: ResolvedProvider<FacebookOAuthCredentials>;
|
|
77
|
+
/** GitHub OAuth: disabled, ready with its credential pair, or declared and unreadable. */
|
|
78
|
+
github: ResolvedProvider<GithubOAuthCredentials>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Every provider disabled — what a composition that enables no social sign-in resolves to, and the
|
|
83
|
+
* base a construction site spreads over rather than spelling four times.
|
|
84
|
+
*/
|
|
85
|
+
export const NO_SOCIAL_PROVIDERS: Readonly<ResolvedProviders> = {
|
|
86
|
+
google: PROVIDER_DISABLED,
|
|
87
|
+
apple: PROVIDER_DISABLED,
|
|
88
|
+
facebook: PROVIDER_DISABLED,
|
|
89
|
+
github: PROVIDER_DISABLED,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve one provider: `disabled` when the adopter did not enable it, `ready` with its credentials, or
|
|
94
|
+
* `unresolvable` when it is enabled and the read failed.
|
|
95
|
+
*
|
|
96
|
+
* **`try`/`catch`, and `read` is invoked inside it.** A `.catch()` on the returned promise would guard
|
|
97
|
+
* only a rejection, and a seam that throws *before* it returns a promise is not a rejected promise —
|
|
98
|
+
* that is how #371's first plant walked straight through its own guard. Calling `read()` inside the
|
|
99
|
+
* `try` catches both, and `providers.test.ts` plants a synchronous throw to prove it.
|
|
100
|
+
*
|
|
101
|
+
* **The `catch` takes no binding.** See {@link ResolvedProvider} — what a secret resolution throws
|
|
102
|
+
* names the secret, and nothing derived from it may travel toward a browser.
|
|
103
|
+
*/
|
|
104
|
+
export async function resolveProvider<Credentials>(
|
|
105
|
+
enabled: boolean,
|
|
106
|
+
read: () => Promise<Credentials>,
|
|
107
|
+
): Promise<ResolvedProvider<Credentials>> {
|
|
108
|
+
if (!enabled) return PROVIDER_DISABLED;
|
|
109
|
+
try {
|
|
110
|
+
return { state: "ready", credentials: await read() };
|
|
111
|
+
} catch {
|
|
112
|
+
return { state: "unresolvable" };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The Better Auth endpoints that name a social provider in their body, as their `ctx.path` reads —
|
|
118
|
+
* mounted paths, so no `basePath` appears here.
|
|
119
|
+
*
|
|
120
|
+
* `/callback/:provider` is deliberately absent: a callback can only arrive for a provider that already
|
|
121
|
+
* served an authorize redirect, and an unresolvable one never did.
|
|
122
|
+
*/
|
|
123
|
+
const PROVIDER_BODY_PATHS: ReadonlySet<string> = new Set(["/sign-in/social", "/link-social"]);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The provider named in a social sign-in body.
|
|
127
|
+
*
|
|
128
|
+
* Parsed rather than cast. Better Auth has already validated the body against the endpoint's own
|
|
129
|
+
* schema by the time a `before` hook runs, so this is not the trust boundary — but a `catchall` shape
|
|
130
|
+
* read with a cast is how a hook starts reading a field that is not there, and `unknown` narrows for
|
|
131
|
+
* free here.
|
|
132
|
+
*/
|
|
133
|
+
const ProviderBody = z
|
|
134
|
+
.object({
|
|
135
|
+
provider: z.string().min(1).describe("The social provider id the caller asked to sign in or link with."),
|
|
136
|
+
})
|
|
137
|
+
.describe("The part of a social sign-in body this refusal reads: which provider was asked for.");
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Which provider this request asks for, when the request is one that names one and that provider is
|
|
141
|
+
* enabled but unreadable. `undefined` for every other request, which is nearly all of them.
|
|
142
|
+
*
|
|
143
|
+
* A provider that is merely *disabled* is not this function's business: Better Auth answers that with
|
|
144
|
+
* its own `PROVIDER_NOT_FOUND` 404, which is the right answer — nobody configured it. This exists so
|
|
145
|
+
* the two stop looking alike from outside the Worker.
|
|
146
|
+
*/
|
|
147
|
+
export function unavailableProviderFor(
|
|
148
|
+
path: string,
|
|
149
|
+
body: unknown,
|
|
150
|
+
providers: ResolvedProviders,
|
|
151
|
+
): SocialProviderId | undefined {
|
|
152
|
+
if (!PROVIDER_BODY_PATHS.has(path)) return undefined;
|
|
153
|
+
const parsed = ProviderBody.safeParse(body);
|
|
154
|
+
if (!parsed.success) return undefined;
|
|
155
|
+
const asked = SOCIAL_PROVIDER_IDS.find((id) => id === parsed.data.provider);
|
|
156
|
+
if (!asked) return undefined;
|
|
157
|
+
return providers[asked].state === "unresolvable" ? asked : undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The refusal a caller gets for a provider this deployment means to serve and cannot.
|
|
162
|
+
*
|
|
163
|
+
* Three audiences, three fields, exactly as the error family prescribes:
|
|
164
|
+
*
|
|
165
|
+
* - `message` is the caller's, and it is the whole of what crosses the wire. It says the method is
|
|
166
|
+
* unavailable *right now* and names one that works, because a 404 saying "provider not found" tells
|
|
167
|
+
* somebody who signs in with GitHub every day that they were wrong about their own account.
|
|
168
|
+
* - `action` is the operator's — the command that fixes it, and the config key that turns it off.
|
|
169
|
+
* - `detail` is the throw site's. It names the provider and says the credential would not resolve. It
|
|
170
|
+
* does **not** carry the resolution's own error: `resolveProvider` dropped that whole, on purpose.
|
|
171
|
+
*
|
|
172
|
+
* `503` rather than `500`, and rather than `404`. This deployment's own dependency — the credential row
|
|
173
|
+
* for one sign-in method — is the thing that could not be served, which is what 503 means and what
|
|
174
|
+
* `payments/provider_unavailable` already uses it for. A 404 would be indistinguishable from a provider
|
|
175
|
+
* nobody enabled, and that indistinguishability is the defect.
|
|
176
|
+
*/
|
|
177
|
+
export function providerUnavailable(provider: SocialProviderId): PithyError {
|
|
178
|
+
return new PithyError({
|
|
179
|
+
code: "auth/provider_unavailable",
|
|
180
|
+
status: 503,
|
|
181
|
+
message: `Signing in with ${provider} is unavailable right now. Use a magic link instead.`,
|
|
182
|
+
action: `Provision \`auth-${provider}-credentials\` for this environment with \`pithy secrets create\`, or set \`${provider}.enabled\` to false in pithy.config.ts.`,
|
|
183
|
+
detail: `provider ${provider} is enabled and its credential could not be resolved; the instance was built without it`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
|
|
5
|
+
import { defineSecretRegistry } from "@pithy-sh/secrets/src/registry";
|
|
6
|
+
import { sharedSecretsStore } from "@pithy-sh/secrets/src/sharedSecretsStore";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The secrets `@pithy-sh/auth` reads, declared as a minimal registry. The secret name is the join key:
|
|
11
|
+
* `secretsStore` resolves the same encrypted D1 row regardless of which capability's registry names it,
|
|
12
|
+
* so auth never needs the project-wide registry to read its own secrets.
|
|
13
|
+
*
|
|
14
|
+
* - `auth-session-secret` — the Better Auth signing/encryption secret. Signs session cookies and the
|
|
15
|
+
* bearer HMAC, and encrypts the JWKS private keys at rest. Rotatable (Better Auth accepts a version
|
|
16
|
+
* set so a rotation keeps prior signatures valid).
|
|
17
|
+
* - `auth-google-credentials` — the Google OAuth client id and secret together, as a typed JSON value.
|
|
18
|
+
* Both travel as one atomic secret so the credential pair is never split across config and store.
|
|
19
|
+
* Read only when the provider is enabled; rotated in Google Cloud Console, not on our schedule.
|
|
20
|
+
* - `auth-apple-credentials` — the Apple Sign-In Services id, client secret, and (optional) app bundle
|
|
21
|
+
* id, as a typed JSON value. Apple's client secret is an ES256 JWT minted from the `.p8` key that
|
|
22
|
+
* expires (max 6 months), so this one is rotatable.
|
|
23
|
+
* - `auth-facebook-credentials` — the Facebook Login app id and client secret, as a typed JSON value.
|
|
24
|
+
* Read only when the provider is enabled; rotated in the Meta app dashboard, not on our schedule.
|
|
25
|
+
* - `auth-github-credentials` — the GitHub OAuth app client id and secret, as a typed JSON value.
|
|
26
|
+
* Read only when the provider is enabled; rotated in GitHub Developer settings, not on our schedule.
|
|
27
|
+
*/
|
|
28
|
+
export const AUTH_SESSION_SECRET = "auth-session-secret";
|
|
29
|
+
export const AUTH_GOOGLE_CREDENTIALS = "auth-google-credentials";
|
|
30
|
+
export const AUTH_APPLE_CREDENTIALS = "auth-apple-credentials";
|
|
31
|
+
export const AUTH_FACEBOOK_CREDENTIALS = "auth-facebook-credentials";
|
|
32
|
+
export const AUTH_GITHUB_CREDENTIALS = "auth-github-credentials";
|
|
33
|
+
|
|
34
|
+
/** The Google OAuth credential pair, stored as one typed JSON secret. */
|
|
35
|
+
export const GoogleOAuthCredentials = z
|
|
36
|
+
.object({
|
|
37
|
+
clientId: z
|
|
38
|
+
.string()
|
|
39
|
+
.describe(
|
|
40
|
+
"The Google OAuth client id for this environment. Not secret on its own, but stored with the secret so the pair is atomic.",
|
|
41
|
+
),
|
|
42
|
+
clientSecret: z
|
|
43
|
+
.string()
|
|
44
|
+
.describe("The Google OAuth client secret for this environment. Never committed, never an env literal."),
|
|
45
|
+
})
|
|
46
|
+
.describe("A Google OAuth client credential pair (`clientId` + `clientSecret`) for one environment.");
|
|
47
|
+
export type GoogleOAuthCredentials = z.infer<typeof GoogleOAuthCredentials>;
|
|
48
|
+
|
|
49
|
+
/** The Apple Sign-In credentials, stored as one typed JSON secret. */
|
|
50
|
+
export const AppleOAuthCredentials = z
|
|
51
|
+
.object({
|
|
52
|
+
clientId: z.string().describe("The Apple Sign-In Services id (the web OAuth client id) for this environment."),
|
|
53
|
+
clientSecret: z
|
|
54
|
+
.string()
|
|
55
|
+
.describe(
|
|
56
|
+
"The Apple client secret — an ES256 JWT minted from the `.p8` key. Expires (max 6 months), so rotate it on schedule.",
|
|
57
|
+
),
|
|
58
|
+
appBundleIdentifier: z
|
|
59
|
+
.string()
|
|
60
|
+
.optional()
|
|
61
|
+
.describe(
|
|
62
|
+
"The native iOS app bundle id, used as the id-token audience for the native Sign-in-with-Apple flow. Optional.",
|
|
63
|
+
),
|
|
64
|
+
})
|
|
65
|
+
.describe(
|
|
66
|
+
"An Apple Sign-In credential set (`clientId` + `clientSecret`, optional `appBundleIdentifier`) for one environment.",
|
|
67
|
+
);
|
|
68
|
+
export type AppleOAuthCredentials = z.infer<typeof AppleOAuthCredentials>;
|
|
69
|
+
|
|
70
|
+
/** The Facebook Login credential pair, stored as one typed JSON secret. */
|
|
71
|
+
export const FacebookOAuthCredentials = z
|
|
72
|
+
.object({
|
|
73
|
+
clientId: z
|
|
74
|
+
.string()
|
|
75
|
+
.describe(
|
|
76
|
+
"The Facebook Login app id for this environment. Not secret on its own, but stored with the secret so the pair is atomic.",
|
|
77
|
+
),
|
|
78
|
+
clientSecret: z
|
|
79
|
+
.string()
|
|
80
|
+
.describe("The Facebook Login app secret for this environment. Never committed, never an env literal."),
|
|
81
|
+
})
|
|
82
|
+
.describe("A Facebook Login credential pair (`clientId` + `clientSecret`) for one environment.");
|
|
83
|
+
export type FacebookOAuthCredentials = z.infer<typeof FacebookOAuthCredentials>;
|
|
84
|
+
|
|
85
|
+
/** The GitHub OAuth credential pair, stored as one typed JSON secret. */
|
|
86
|
+
export const GithubOAuthCredentials = z
|
|
87
|
+
.object({
|
|
88
|
+
clientId: z
|
|
89
|
+
.string()
|
|
90
|
+
.describe(
|
|
91
|
+
"The GitHub OAuth app client id for this environment. Not secret on its own, but stored with the secret so the pair is atomic.",
|
|
92
|
+
),
|
|
93
|
+
clientSecret: z
|
|
94
|
+
.string()
|
|
95
|
+
.describe("The GitHub OAuth app client secret for this environment. Never committed, never an env literal."),
|
|
96
|
+
})
|
|
97
|
+
.describe("A GitHub OAuth credential pair (`clientId` + `clientSecret`) for one environment.");
|
|
98
|
+
export type GithubOAuthCredentials = z.infer<typeof GithubOAuthCredentials>;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Where a human goes for each OAuth credential pair, and where the same human rotates it.
|
|
102
|
+
*
|
|
103
|
+
* **The settings page, not the product homepage.** The point is to end a search, not to start one — and
|
|
104
|
+
* these four are the case with no command and no possible one, so the link is the entire remedy the kit
|
|
105
|
+
* can offer. Named constants because origin and rotation are the same page for all four, and two copies
|
|
106
|
+
* of a URL is one that goes stale.
|
|
107
|
+
*/
|
|
108
|
+
const GOOGLE_CREDENTIALS_PAGE = "https://console.cloud.google.com/apis/credentials";
|
|
109
|
+
const APPLE_KEYS_PAGE = "https://developer.apple.com/account/resources/authkeys/list";
|
|
110
|
+
const FACEBOOK_APPS_PAGE = "https://developers.facebook.com/apps/";
|
|
111
|
+
const GITHUB_OAUTH_APPS_PAGE = "https://github.com/settings/developers";
|
|
112
|
+
|
|
113
|
+
export const authSecretsRegistry = defineSecretRegistry({
|
|
114
|
+
// `devValue` is the whole difference between an app that runs after `pithy add auth` and one that
|
|
115
|
+
// signs nobody in: this secret is not a required binding, so nothing names it until the first
|
|
116
|
+
// sign-in. Any random string signs a session — nothing outside the project has to agree with it.
|
|
117
|
+
[AUTH_SESSION_SECRET]: {
|
|
118
|
+
backend: "d1",
|
|
119
|
+
scope: "environment",
|
|
120
|
+
rotatable: true,
|
|
121
|
+
valueType: "text",
|
|
122
|
+
devValue: "random",
|
|
123
|
+
// `project` is the issuer, which is the whole reason this one is different: nobody outside validates
|
|
124
|
+
// a session signature, so the kit both makes it and replaces it.
|
|
125
|
+
origin: { kind: "minted", recipe: { kind: "random", bytes: 32, encoding: "base64url" } },
|
|
126
|
+
rotation: { kind: "local" },
|
|
127
|
+
},
|
|
128
|
+
// The four below are `obtained` and rotate `manual`, and each names the page rather than the product.
|
|
129
|
+
// An adopter searching Google's console for which of four credential types "client secret" means is the
|
|
130
|
+
// gap this closes — and nothing will ever mint one, so saying where is the whole of what the kit can do.
|
|
131
|
+
[AUTH_GOOGLE_CREDENTIALS]: {
|
|
132
|
+
backend: "d1",
|
|
133
|
+
scope: "environment",
|
|
134
|
+
rotatable: false,
|
|
135
|
+
valueType: "json",
|
|
136
|
+
schema: GoogleOAuthCredentials,
|
|
137
|
+
origin: { kind: "obtained", issuer: "google", documentation: GOOGLE_CREDENTIALS_PAGE },
|
|
138
|
+
rotation: { kind: "manual", issuer: "google", documentation: GOOGLE_CREDENTIALS_PAGE },
|
|
139
|
+
},
|
|
140
|
+
[AUTH_APPLE_CREDENTIALS]: {
|
|
141
|
+
backend: "d1",
|
|
142
|
+
scope: "environment",
|
|
143
|
+
rotatable: true,
|
|
144
|
+
valueType: "json",
|
|
145
|
+
schema: AppleOAuthCredentials,
|
|
146
|
+
origin: { kind: "obtained", issuer: "apple", documentation: APPLE_KEYS_PAGE },
|
|
147
|
+
rotation: { kind: "manual", issuer: "apple", documentation: APPLE_KEYS_PAGE },
|
|
148
|
+
},
|
|
149
|
+
[AUTH_FACEBOOK_CREDENTIALS]: {
|
|
150
|
+
backend: "d1",
|
|
151
|
+
scope: "environment",
|
|
152
|
+
rotatable: false,
|
|
153
|
+
valueType: "json",
|
|
154
|
+
schema: FacebookOAuthCredentials,
|
|
155
|
+
origin: { kind: "obtained", issuer: "facebook", documentation: FACEBOOK_APPS_PAGE },
|
|
156
|
+
rotation: { kind: "manual", issuer: "facebook", documentation: FACEBOOK_APPS_PAGE },
|
|
157
|
+
},
|
|
158
|
+
[AUTH_GITHUB_CREDENTIALS]: {
|
|
159
|
+
backend: "d1",
|
|
160
|
+
scope: "environment",
|
|
161
|
+
rotatable: false,
|
|
162
|
+
valueType: "json",
|
|
163
|
+
schema: GithubOAuthCredentials,
|
|
164
|
+
origin: { kind: "obtained", issuer: "github", documentation: GITHUB_OAUTH_APPS_PAGE },
|
|
165
|
+
rotation: { kind: "manual", issuer: "github", documentation: GITHUB_OAUTH_APPS_PAGE },
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/** Resolve the Better Auth session secret for this invocation. */
|
|
170
|
+
export async function resolveSessionSecret(env: SecretsStoreEnv): Promise<string> {
|
|
171
|
+
const secrets = await sharedSecretsStore(env, authSecretsRegistry);
|
|
172
|
+
return secrets.get(AUTH_SESSION_SECRET);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Resolve the Google OAuth credential pair for this invocation (only call when Google is enabled). */
|
|
176
|
+
export async function resolveGoogleCredentials(env: SecretsStoreEnv): Promise<GoogleOAuthCredentials> {
|
|
177
|
+
const secrets = await sharedSecretsStore(env, authSecretsRegistry);
|
|
178
|
+
return secrets.get(AUTH_GOOGLE_CREDENTIALS);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Resolve the Apple Sign-In credentials for this invocation (only call when Apple is enabled). */
|
|
182
|
+
export async function resolveAppleCredentials(env: SecretsStoreEnv): Promise<AppleOAuthCredentials> {
|
|
183
|
+
const secrets = await sharedSecretsStore(env, authSecretsRegistry);
|
|
184
|
+
return secrets.get(AUTH_APPLE_CREDENTIALS);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Resolve the Facebook Login credential pair for this invocation (only call when Facebook is enabled). */
|
|
188
|
+
export async function resolveFacebookCredentials(env: SecretsStoreEnv): Promise<FacebookOAuthCredentials> {
|
|
189
|
+
const secrets = await sharedSecretsStore(env, authSecretsRegistry);
|
|
190
|
+
return secrets.get(AUTH_FACEBOOK_CREDENTIALS);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Resolve the GitHub OAuth credential pair for this invocation (only call when GitHub is enabled). */
|
|
194
|
+
export async function resolveGithubCredentials(env: SecretsStoreEnv): Promise<GithubOAuthCredentials> {
|
|
195
|
+
const secrets = await sharedSecretsStore(env, authSecretsRegistry);
|
|
196
|
+
return secrets.get(AUTH_GITHUB_CREDENTIALS);
|
|
197
|
+
}
|