@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/docs/apple-signin.md +139 -0
  4. package/docs/facebook-oauth.md +92 -0
  5. package/docs/github-oauth.md +99 -0
  6. package/docs/google-oauth.md +118 -0
  7. package/package.json +58 -0
  8. package/pithy.manifest.json +108 -0
  9. package/src/admin/users.ts +357 -0
  10. package/src/audit/actions.ts +71 -0
  11. package/src/audit/emit.ts +223 -0
  12. package/src/capability.ts +300 -0
  13. package/src/client/api.ts +501 -0
  14. package/src/client/projection.ts +55 -0
  15. package/src/cloudflare-test.d.ts +16 -0
  16. package/src/data/betterAuth.ts +210 -0
  17. package/src/data/device.ts +57 -0
  18. package/src/data/kitFields.ts +69 -0
  19. package/src/data/rotatedToken.ts +40 -0
  20. package/src/data/tables.ts +38 -0
  21. package/src/device/registry.ts +139 -0
  22. package/src/email/send.ts +67 -0
  23. package/src/http/adminRoutes.ts +368 -0
  24. package/src/http/baseUrl.ts +109 -0
  25. package/src/http/csrf.ts +98 -0
  26. package/src/http/devLoginRoute.ts +159 -0
  27. package/src/http/errors.ts +70 -0
  28. package/src/http/guards.ts +158 -0
  29. package/src/http/middleware.ts +67 -0
  30. package/src/http/rateLimit.ts +36 -0
  31. package/src/http/resolve.ts +152 -0
  32. package/src/http/responses.ts +199 -0
  33. package/src/http/routes.ts +325 -0
  34. package/src/http/schemas.ts +118 -0
  35. package/src/http/views.ts +93 -0
  36. package/src/i18n/errorCopy.es.ts +35 -0
  37. package/src/i18n/errorCopy.ts +99 -0
  38. package/src/index.ts +24 -0
  39. package/src/instance/auth.ts +309 -0
  40. package/src/instance/plugins.ts +172 -0
  41. package/src/instance/providers.ts +185 -0
  42. package/src/instance/secrets.ts +197 -0
  43. package/src/migrations/0001_init.ts +229 -0
  44. package/src/migrations/pluginTables.ts +334 -0
  45. package/src/seeds/devSession.ts +286 -0
  46. package/src/seeds/example.ts +48 -0
  47. package/src/test-utils/liveApp.ts +338 -0
  48. package/src/token/rotation.ts +104 -0
  49. package/src/version.generated.ts +16 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pithy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @pithy-sh/auth
2
+
3
+ Passwordless auth for Cloudflare. Magic link, email OTP, Google, Apple. Mobile and web, both first-class. Built on Better Auth. No email and password, ever.
4
+
5
+ This capability fills core's identity seams. It mints sessions, issues short-lived JWT access tokens, registers devices, and validates every request — so other capabilities just call `requireAuth()`.
6
+
7
+ ```sh
8
+ pithy add auth
9
+ ```
10
+
11
+ **Documentation: [pithy.sh/docs/capabilities/auth](https://pithy.sh/docs/capabilities/auth).** Overview, adding it, using it, and the reference: the token model, sessions and devices, JWKS.
12
+
13
+ _Everything else is on the site. `pithy.sh/docs` is canonical — new prose goes there, not here._
14
+
15
+ ## The client surface
16
+
17
+ Better Auth builds a client from its **own** plugin list. The server's type never crosses into a browser bundle, so composing `organization()` on the server is half of it — add `organizationClient()` beside it and `authClient.organization` is fully typed, with no cast:
18
+
19
+ ```ts
20
+ import { createAuthClient } from "better-auth/client";
21
+ import { emailOTPClient, magicLinkClient, organizationClient } from "better-auth/client/plugins";
22
+
23
+ export const authClient = createAuthClient({
24
+ baseURL: "https://api.example.com",
25
+ basePath: "/auth",
26
+ plugins: [magicLinkClient(), emailOTPClient(), organizationClient()],
27
+ });
28
+ ```
29
+
30
+ The kit's own sign-in plugins have client halves too, and they go in the same list — nothing about the client is inherited from the server.
31
+
32
+ The one thing that does need the server's type is `inferAdditionalFields`, which teaches the client about extra user and session fields. `AuthInstance` is parameterized in the plugin tuple for exactly that:
33
+
34
+ ```ts
35
+ import type { AuthInstance } from "@pithy-sh/auth/src/instance/auth";
36
+ import type { organization } from "better-auth/plugins/organization";
37
+
38
+ type AppAuth = AuthInstance<[ReturnType<typeof organization>]>;
39
+ // …then `inferAdditionalFields<AppAuth>()` in the plugins list above.
40
+ ```
41
+
42
+ **This section stays here.** `src/http/routes.ts` and `src/client/api.ts` both explain a design decision by pointing a reader at it by name — the flat response shape is read rather than rewritten precisely because `createAuthClient` is a first-class surface, and that argument is only checkable against the client this documents.
43
+
44
+ ## License
45
+
46
+ MIT — adopter-side app value. The root `LICENSE` covers it.
@@ -0,0 +1,139 @@
1
+ # Apple sign-in
2
+
3
+ _The reader's version of this page is [pithy.sh/docs/build/auth-and-accounts/apple-sign-in](https://pithy.sh/docs/build/auth-and-accounts/apple-sign-in). This copy ships in the package because `packages/auth/src/capability.ts` sends an adopter to it by name._
4
+
5
+ Adding Sign in with Apple to `@pithy-sh/auth`. Step by step.
6
+
7
+ Apple is first-class here because Pithy is mobile-first. Apple's App Store guidelines require you to offer Sign in with Apple once you offer another third-party sign-in like Google. If your app ships Google, it ships Apple too.
8
+
9
+ ## Why this part is manual
10
+
11
+ Sign in with Apple credentials are created in the [Apple Developer portal](https://developer.apple.com) by a human with access to your Apple Developer account. Pithy cannot create them for you — there is no API to provision an App ID, a Services ID, or a signing key on your behalf. So this is a one-time manual setup. The rest of the flow is config.
12
+
13
+ There is one extra wrinkle Google does not have: Apple's client secret is not a static string. It is a JWT you sign yourself, and it expires. More on that in [Generate the client secret](#4-generate-the-client-secret).
14
+
15
+ ## 1. Register an App ID
16
+
17
+ Under **Certificates, Identifiers & Profiles** → **Identifiers** → **App IDs**, register an App ID for your iOS app. Enable the **Sign in with Apple** capability on it.
18
+
19
+ The App ID's bundle id — for example `com.example.myapp` — is the `appBundleIdentifier`. The native iOS flow uses it as the id-token audience. Note it down.
20
+
21
+ ## 2. Create a Services ID
22
+
23
+ Under **Identifiers** → **Services IDs**, create a Services ID. This is the OAuth `clientId` for the web and redirect sign-in flow — distinct from the App ID above.
24
+
25
+ Enable **Sign in with Apple** on the Services ID and configure it. Add your web domain and the return URL from the next section. Apple requires the domain be verified.
26
+
27
+ ## 3. Create a Sign in with Apple key
28
+
29
+ Under **Keys**, create a new key with **Sign in with Apple** enabled. On create you can download the private key once as a `.p8` file — download it and keep it safe. You cannot download it again.
30
+
31
+ Note two values:
32
+
33
+ - The **Key ID** shown on the key.
34
+ - Your **Team ID** — the ten-character id in your Apple Developer account membership.
35
+
36
+ You now hold three things: the `.p8` private key, its Key ID, and your Team ID.
37
+
38
+ ## 4. Generate the client secret
39
+
40
+ Apple's client secret is not a string you copy. It is an **ES256 JWT** you sign with the `.p8` private key. The claims:
41
+
42
+ | Claim | Value |
43
+ | --- | --- |
44
+ | `iss` | your Team ID |
45
+ | `sub` | your Services ID (the `clientId`) |
46
+ | `aud` | `https://appleid.apple.com` |
47
+ | `exp` | an expiry **at most six months** out |
48
+
49
+ That signed JWT **is** the `clientSecret`. Sign it with the `.p8`, the Key ID in the JWT header (`kid`), and `alg: ES256`.
50
+
51
+ Because `exp` caps at six months, the secret expires. Regenerate and rotate it on a schedule — the secret is stored as **rotatable** so a fresh JWT can replace the old one without a config change. See [Where the credentials live](#where-the-credentials-live).
52
+
53
+ ## 5. The exact return URL
54
+
55
+ Better Auth's Apple callback is always:
56
+
57
+ ```
58
+ <baseURL><basePath>/callback/apple
59
+ ```
60
+
61
+ With the default `basePath` of `/auth`, the path is `/auth/callback/apple`. Register one return URL per environment in the Services ID, each on that environment's `baseURL` host:
62
+
63
+ | Environment | Return URL |
64
+ | --- | --- |
65
+ | dev | `http://localhost:8787/auth/callback/apple` |
66
+ | staging | `https://staging.<your-domain>/auth/callback/apple` |
67
+ | production | `https://<your-domain>/auth/callback/apple` |
68
+
69
+ Apple requires the domain be verified and uses https. It does not accept plain http, so localhost generally will not work as a return URL — dev typically uses the native iOS flow or a tunneled https domain instead.
70
+
71
+ Apple sends the callback as a **POST** (`form_post`), not a redirect GET. Better Auth handles that. You do not need to change anything for it.
72
+
73
+ ### Every environment — and why feature-branch previews won't work
74
+
75
+ Each environment needs its **own** return URL registered on the Services ID, on that environment's exact `baseURL` host — and Apple additionally requires that host's domain be **verified**, over https. Apple only returns to a URL you have registered.
76
+
77
+ Apple is the strictest here for feature branches. A branch deployed to a Cloudflare preview URL — an ephemeral `*.workers.dev` or preview alias, not your registered `staging.<your-domain>` — is neither registered nor domain-verified, so web Apple sign-in there simply cannot complete. Registering and verifying a throwaway preview domain per branch is impractical, so do not expect to test the web Apple flow from a preview URL.
78
+
79
+ To exercise Apple on a branch, use the **native iOS flow** (which sends an id token straight to the Worker and needs no return URL), or run against your registered `staging`/`production` environment.
80
+
81
+ Magic link and email OTP have no return URL — they work on any URL, preview or not. Only the OAuth providers need a registered callback.
82
+
83
+ ## 6. Mobile
84
+
85
+ Native iOS uses Apple's own Sign in with Apple flow, not the web redirect. The app gets an identity token from Apple and sends it to the Worker. The `appBundleIdentifier` is the audience that id token is validated against — which is why it is part of the credential.
86
+
87
+ You do **not** register a deep-link or custom-scheme URL in the Apple portal. Apple only ever returns to the Services ID's return URL or, for native, hands the id token straight to the app.
88
+
89
+ The app passes its own deep link as the sign-in `callbackURL` — for example `myapp://auth/callback`. For that to be allowed, the scheme must be listed in `trustedOrigins` (prefix match):
90
+
91
+ ```ts
92
+ auth({
93
+ trustedOrigins: ["myapp://", "https://app.example.com"],
94
+ });
95
+ ```
96
+
97
+ ## Where the credentials live
98
+
99
+ **All three values travel as one typed JSON secret.** The Services ID, the signed JWT, and the bundle id are stored together — through `@pithy-sh/secrets`, never committed, never an env literal — as `auth-apple-credentials`:
100
+
101
+ ```
102
+ pithy secrets create auth-apple-credentials --json '{"clientId":"<services-id>","clientSecret":"<es256-jwt>","appBundleIdentifier":"<ios-bundle-id>"}'
103
+ ```
104
+
105
+ `appBundleIdentifier` is optional. Omit it for web-only. The package reads the whole credential from the secrets store at request time and wires it into the provider. Enable Apple in config with `auth({ apple: { enabled: true } })` — no credential values in the config.
106
+
107
+ The secret is **rotatable**. When the JWT nears its six-month expiry, generate a fresh one and rotate the secret. Read sites stay byte-identical.
108
+
109
+ ## Account linking
110
+
111
+ A user who signed up with a magic link and later signs in with Apple is linked automatically when the verified emails match. Apple is configured as a **trusted provider**, and the local email is already verified — the magic link proved it — so the two accounts merge into one rather than colliding.
112
+
113
+ Apple only returns the user's name on the **first** authorization. Better Auth persists it then. If you wipe the user and re-authorize, the name does not come back unless you remove the app from the Apple ID's signed-in apps first. There is nothing to configure; just know the name arrives once.
114
+
115
+ `requireLocalEmailVerified` stays on. That is the secure default: it blocks account takeover where an attacker pre-registers an unverified row for a victim's email and waits for the victim's Apple sign-in to link into it. Leave it on.
116
+
117
+ ## When the credential will not read
118
+
119
+ An enabled provider whose secret is missing, or whose stored value no longer matches its schema, costs
120
+ **that provider and nothing else**. Magic link, OTP, and every other provider keep signing people in.
121
+
122
+ A sign-in attempt with Apple then answers `503` with the code `auth/provider_unavailable` and a
123
+ message naming apple, rather than the `404 PROVIDER_NOT_FOUND` a provider nobody enabled gets — the two
124
+ are different facts and never share an answer. The attempt is recorded in the audit trail as
125
+ `auth/provider_unavailable`, which is where an operator learns a sign-in method is down. Fix it by
126
+ provisioning `auth-apple-credentials` for that environment, or turn the provider off in config.
127
+
128
+ ## Checklist
129
+
130
+ - [ ] App ID registered with Sign in with Apple enabled; bundle id in hand.
131
+ - [ ] Services ID created with Sign in with Apple configured; web domain verified.
132
+ - [ ] Sign in with Apple key created; `.p8` downloaded, Key ID and Team ID noted.
133
+ - [ ] Client secret JWT generated: ES256, `iss` = Team ID, `sub` = Services ID, `aud` = `https://appleid.apple.com`, `exp` ≤ 6 months.
134
+ - [ ] Return URL registered per environment as `<baseURL><basePath>/callback/apple`.
135
+ - [ ] Return URL host matches each environment's `baseURL`; domain verified and https.
136
+ - [ ] Mobile deep-link scheme listed in `trustedOrigins` (no deep link in the Apple portal).
137
+ - [ ] `clientId` + `clientSecret` + optional `appBundleIdentifier` stored together via `pithy secrets create auth-apple-credentials` (typed JSON); Apple enabled in config with `apple: { enabled: true }`.
138
+ - [ ] A plan to regenerate and rotate the client secret before it expires.
139
+ - [ ] `requireLocalEmailVerified` left on.
@@ -0,0 +1,92 @@
1
+ # Facebook sign-in
2
+
3
+ _The reader's version of this page is [pithy.sh/docs/build/auth-and-accounts/facebook-sign-in](https://pithy.sh/docs/build/auth-and-accounts/facebook-sign-in). This copy ships in the package because `packages/auth/src/capability.ts` sends an adopter to it by name._
4
+
5
+ Adding Facebook to `@pithy-sh/auth`. Step by step.
6
+
7
+ ## Why this part is manual
8
+
9
+ Facebook Login credentials are minted in the Meta app dashboard by a human with access to your Meta developer account. Pithy cannot create them for you. So this is a one-time manual setup. The rest of the flow is config.
10
+
11
+ ## 1. Create a Meta app
12
+
13
+ Open the [Meta app dashboard](https://developers.facebook.com/apps). Create an app, choose **Consumer** (or the type that fits), and add the **Facebook Login** product. One app covers all your environments; you register a redirect URI per environment below.
14
+
15
+ ## 2. The exact redirect URI
16
+
17
+ Better Auth's OAuth `redirect_uri` is always:
18
+
19
+ ```
20
+ <baseURL><basePath>/callback/facebook
21
+ ```
22
+
23
+ With the default `basePath` of `/auth`, the path is `/auth/callback/facebook`. Under **Facebook Login → Settings → Valid OAuth Redirect URIs**, register one URI per environment, each on that environment's `baseURL` host:
24
+
25
+ | Environment | Redirect URI |
26
+ | --- | --- |
27
+ | dev | `http://localhost:8787/auth/callback/facebook` |
28
+ | staging | `https://staging.<your-domain>/auth/callback/facebook` |
29
+ | production | `https://<your-domain>/auth/callback/facebook` |
30
+
31
+ Use your actual local port for dev — `8787` is wrangler's default, not a guarantee. Facebook requires HTTPS for non-localhost redirect URIs.
32
+
33
+ ### Every environment — and why feature-branch previews won't work
34
+
35
+ Each environment needs its **own** redirect URI in the app's **Valid OAuth Redirect URIs**, on that environment's exact `baseURL` host. Facebook only accepts a URL you have listed; anything else is blocked with *"URL blocked: This redirect failed because it's not in the app's allowed redirect URIs."*
36
+
37
+ This bites feature-branch previews. A branch deployed to a Cloudflare preview URL — an ephemeral `*.workers.dev` or preview alias, not your registered `staging.<your-domain>` — is a host Facebook has never seen, so Facebook sign-in there fails. You cannot exercise Facebook sign-in from a preview URL until that exact URL is listed.
38
+
39
+ To test Facebook on a branch, either add that deployment's own `<its-url>/auth/callback/facebook` to the Valid OAuth Redirect URIs **and** point that deployment's `baseURL` at the same host, or run the flow against your registered `staging` environment instead.
40
+
41
+ Magic link and email OTP have no redirect URI — they work on any URL, preview or not. Only the OAuth providers need a registered callback.
42
+
43
+ ## 3. Scope
44
+
45
+ Pithy requests the `email` scope. You do not configure scopes anywhere — Pithy sets it for you when Facebook is enabled.
46
+
47
+ ## 4. Mobile
48
+
49
+ You do not register a deep-link URI with Facebook. Facebook only ever redirects to the Worker's `/auth/callback/facebook`. The mobile app passes its own deep link as the sign-in `callbackURL` (for example `myapp://auth/callback`); the Worker completes the exchange and redirects there. For that to be allowed, the scheme must be listed in `trustedOrigins` (prefix match):
50
+
51
+ ```ts
52
+ auth({
53
+ trustedOrigins: ["myapp://", "https://app.example.com"],
54
+ });
55
+ ```
56
+
57
+ ## Where the credentials live
58
+
59
+ **Both credentials travel as one typed JSON secret.** The app id and app secret are stored together — through `@pithy-sh/secrets`, never committed, never an env literal — as `auth-facebook-credentials`:
60
+
61
+ ```
62
+ pithy secrets create auth-facebook-credentials --json '{"clientId":"<your-app-id>","clientSecret":"<your-app-secret>"}'
63
+ ```
64
+
65
+ Enable Facebook in config with `auth({ facebook: { enabled: true } })` — no credential values in the config.
66
+
67
+ ## Account linking
68
+
69
+ One account per verified email. A Facebook sign-in whose email matches an existing user links into that account — no second account.
70
+
71
+ Pithy trusts Facebook's email as verified. Facebook confirms a user's email before it will hand it back, and Pithy validates the OAuth access token against your app before reading the profile — so the email is genuinely the signed-in Facebook user's, verified by Facebook. That is the same trust Google and Apple get. Better Auth's own OAuth response carries no `email_verified` claim (and the Graph API exposes no such field), so without this Facebook would treat every email as unverified; Pithy asserts verification for Facebook's own email only.
72
+
73
+ Facebook is **not** in `trustedProviders`. Trusting the returned email is not the same as trusting Facebook to link an *unverified* address — the assertion applies to the authenticated user's own Facebook email, which Facebook has verified.
74
+
75
+ ## When the credential will not read
76
+
77
+ An enabled provider whose secret is missing, or whose stored value no longer matches its schema, costs
78
+ **that provider and nothing else**. Magic link, OTP, and every other provider keep signing people in.
79
+
80
+ A sign-in attempt with Facebook then answers `503` with the code `auth/provider_unavailable` and a
81
+ message naming facebook, rather than the `404 PROVIDER_NOT_FOUND` a provider nobody enabled gets — the two
82
+ are different facts and never share an answer. The attempt is recorded in the audit trail as
83
+ `auth/provider_unavailable`, which is where an operator learns a sign-in method is down. Fix it by
84
+ provisioning `auth-facebook-credentials` for that environment, or turn the provider off in config.
85
+
86
+ ## Checklist
87
+
88
+ - [ ] Meta app created with the Facebook Login product.
89
+ - [ ] Valid OAuth Redirect URI set to `<baseURL><basePath>/callback/facebook`, host matching each environment's `baseURL`.
90
+ - [ ] App ID and app secret in hand.
91
+ - [ ] Mobile deep-link scheme listed in `trustedOrigins` (no deep link in Facebook).
92
+ - [ ] `clientId` + `clientSecret` stored together via `pithy secrets create auth-facebook-credentials` (typed JSON); Facebook enabled in config with `facebook: { enabled: true }`.
@@ -0,0 +1,99 @@
1
+ # GitHub sign-in
2
+
3
+ _The reader's version of this page is [pithy.sh/docs/build/auth-and-accounts/github-sign-in](https://pithy.sh/docs/build/auth-and-accounts/github-sign-in). This copy ships in the package because `packages/auth/src/capability.ts` sends an adopter to it by name._
4
+
5
+ Adding GitHub to `@pithy-sh/auth`. Step by step.
6
+
7
+ ## Why this part is manual
8
+
9
+ GitHub OAuth credentials are minted in GitHub Developer settings by a human with access to your GitHub account or organization. Pithy cannot create them for you. So this is a one-time manual setup. The rest of the flow is config.
10
+
11
+ ## 1. Register an OAuth app
12
+
13
+ Open **GitHub → Settings → Developer settings → OAuth Apps → New OAuth App** (for an org, use the organization's settings). One app per environment is cleanest, because each registers a single callback URL.
14
+
15
+ - **Application name**: whatever your users should see on the consent screen.
16
+ - **Homepage URL**: your app or site.
17
+ - **Authorization callback URL**: the exact redirect URI from the next section.
18
+
19
+ On create you get a **Client ID**. Generate a **Client Secret** on the same page. Where each goes is in [Where the credentials live](#where-the-credentials-live).
20
+
21
+ ## 2. The exact redirect URI
22
+
23
+ Better Auth's OAuth `redirect_uri` is always:
24
+
25
+ ```
26
+ <baseURL><basePath>/callback/github
27
+ ```
28
+
29
+ With the default `basePath` of `/auth`, the path is `/auth/callback/github`. Register one URL per environment, each on that environment's `baseURL` host:
30
+
31
+ | Environment | Callback URL |
32
+ | --- | --- |
33
+ | dev | `http://localhost:8787/auth/callback/github` |
34
+ | staging | `https://staging.<your-domain>/auth/callback/github` |
35
+ | production | `https://<your-domain>/auth/callback/github` |
36
+
37
+ Use your actual local port for dev — `8787` is wrangler's default, not a guarantee. A GitHub OAuth app allows one callback URL, so register a separate app per environment.
38
+
39
+ ### Every environment — and why feature-branch previews won't work
40
+
41
+ Each environment needs its **own** OAuth app, whose single **Authorization callback URL** is that environment's exact `baseURL` host. GitHub only accepts the one callback URL you registered on the app; anything else is rejected with *"The redirect_uri MUST match the registered callback URL for this application."*
42
+
43
+ This bites feature-branch previews. A branch deployed to a Cloudflare preview URL — an ephemeral `*.workers.dev` or preview alias, not your registered `staging.<your-domain>` — is not the callback URL on any of your apps, so GitHub sign-in there fails. You cannot exercise GitHub sign-in from a preview URL until an app is pointed at that exact URL.
44
+
45
+ To test GitHub on a branch, either register a throwaway OAuth app whose callback URL is that deployment's `<its-url>/auth/callback/github` (and point its credentials + `baseURL` at that deployment), or run the flow against your registered `staging` environment instead. Because a GitHub app allows only one callback URL, a per-branch app is the only way to test GitHub on an ephemeral URL.
46
+
47
+ Magic link and email OTP have no callback URL — they work on any URL, preview or not. Only the OAuth providers need a registered callback.
48
+
49
+ ## 3. Scope
50
+
51
+ Pithy requests `user:email`. That is what lets the sign-in read your primary email and its verified status from GitHub's emails API. You do not configure scopes anywhere — Pithy sets this for you when GitHub is enabled.
52
+
53
+ ## 4. Mobile
54
+
55
+ You do not register a deep-link URI with GitHub. GitHub only ever redirects to the Worker's `/auth/callback/github`. The mobile app passes its own deep link as the sign-in `callbackURL` (for example `myapp://auth/callback`); the Worker completes the exchange and redirects there. For that to be allowed, the scheme must be listed in `trustedOrigins` (prefix match):
56
+
57
+ ```ts
58
+ auth({
59
+ trustedOrigins: ["myapp://", "https://app.example.com"],
60
+ });
61
+ ```
62
+
63
+ ## Where the credentials live
64
+
65
+ **Both credentials travel as one typed JSON secret.** The client id and client secret are stored together — through `@pithy-sh/secrets`, never committed, never an env literal — as `auth-github-credentials`:
66
+
67
+ ```
68
+ pithy secrets create auth-github-credentials --json '{"clientId":"<your-client-id>","clientSecret":"<your-client-secret>"}'
69
+ ```
70
+
71
+ Enable GitHub in config with `auth({ github: { enabled: true } })` — no credential values in the config.
72
+
73
+ ## Account linking
74
+
75
+ One account per verified email. GitHub is **not** a trusted provider, so it links only on a verified email — a deliberate choice, because GitHub lets an account hold unverified addresses.
76
+
77
+ - **Verified email → automatic link.** A GitHub sign-in whose primary email is verified on GitHub, matching an existing user, links into that account. No second account.
78
+ - **Unverified email → verify first.** If your GitHub primary email is not verified on GitHub, Pithy will not silently link it and will not create a second account. Verify the email on GitHub, or sign in with a magic link to that address first, then connect GitHub. This closes the takeover hole where an unverified address could be linked to an account you do not own.
79
+ - **No seeding.** A GitHub sign-in whose email is unverified is refused rather than used to create a fresh account — so no one can seed a row at an address they have not proven they own.
80
+
81
+ ## When the credential will not read
82
+
83
+ An enabled provider whose secret is missing, or whose stored value no longer matches its schema, costs
84
+ **that provider and nothing else**. Magic link, OTP, and every other provider keep signing people in.
85
+
86
+ A sign-in attempt with GitHub then answers `503` with the code `auth/provider_unavailable` and a
87
+ message naming github, rather than the `404 PROVIDER_NOT_FOUND` a provider nobody enabled gets — the two
88
+ are different facts and never share an answer. The attempt is recorded in the audit trail as
89
+ `auth/provider_unavailable`, which is where an operator learns a sign-in method is down. Fix it by
90
+ provisioning `auth-github-credentials` for that environment, or turn the provider off in config.
91
+
92
+ ## Checklist
93
+
94
+ - [ ] GitHub OAuth app registered (one per environment).
95
+ - [ ] Authorization callback URL set to `<baseURL><basePath>/callback/github`, host matching each environment's `baseURL`.
96
+ - [ ] Client ID and a generated client secret in hand.
97
+ - [ ] Mobile deep-link scheme listed in `trustedOrigins` (no deep link in GitHub).
98
+ - [ ] `clientId` + `clientSecret` stored together via `pithy secrets create auth-github-credentials` (typed JSON); GitHub enabled in config with `github: { enabled: true }`.
99
+ - [ ] GitHub primary email verified on GitHub (or plan for the magic-link verify-to-link step).
@@ -0,0 +1,118 @@
1
+ # Google sign-in
2
+
3
+ _The reader's version of this page is [pithy.sh/docs/build/auth-and-accounts/google-sign-in](https://pithy.sh/docs/build/auth-and-accounts/google-sign-in). This copy ships in the package because `packages/auth/src/capability.ts` sends an adopter to it by name._
4
+
5
+ Adding Google to `@pithy-sh/auth`. Step by step.
6
+
7
+ ## Why this part is manual
8
+
9
+ Google OAuth credentials are minted in Google Cloud Console by a human with access to your Google account. Pithy cannot create them for you — there is no API to provision an OAuth client on your behalf. So this is a one-time manual setup. The rest of the flow is config.
10
+
11
+ ## 1. Create a Google Cloud project
12
+
13
+ Open the [Google Cloud Console](https://console.cloud.google.com). Create a project, or pick an existing one. One project covers all your environments; you register a separate redirect URI per environment below.
14
+
15
+ ## 2. Configure the OAuth consent screen
16
+
17
+ Under **APIs & Services** → **OAuth consent screen**:
18
+
19
+ - User type: **External**.
20
+ - Add the scopes `email`, `profile`, and `openid`. Nothing more — Pithy only needs identity.
21
+ - Fill the app name, support email, and developer contact.
22
+
23
+ While the app is in **Testing**, only test users you list can sign in. Publish it when you are ready for real users.
24
+
25
+ ## 3. Create OAuth client credentials
26
+
27
+ Under **APIs & Services** → **Credentials** → **Create credentials** → **OAuth client ID** → **Web application**.
28
+
29
+ Name it, then register the authorized redirect URIs from the next section. On create you get a **client ID** and a **client secret**. Where each goes is in [Where the credentials live](#where-the-credentials-live).
30
+
31
+ ## 4. The exact redirect URI
32
+
33
+ Better Auth's OAuth `redirect_uri` is always:
34
+
35
+ ```
36
+ <baseURL><basePath>/callback/google
37
+ ```
38
+
39
+ With the default `basePath` of `/auth`, the path is `/auth/callback/google`. Register one URI per environment, each on that environment's `baseURL` host:
40
+
41
+ | Environment | Redirect URI |
42
+ | --- | --- |
43
+ | dev | `http://localhost:8787/auth/callback/google` |
44
+ | staging | `https://staging.<your-domain>/auth/callback/google` |
45
+ | production | `https://<your-domain>/auth/callback/google` |
46
+
47
+ Use your actual local port for dev — `8787` is wrangler's default, not a guarantee.
48
+
49
+ Two rules. The path is `basePath` + `/callback/google` — if you set a custom `basePath`, the path changes to match it. The host must equal each environment's `baseURL` exactly. A mismatch is the most common cause of `redirect_uri_mismatch`.
50
+
51
+ ### Every environment — and why feature-branch previews won't work
52
+
53
+ Each environment your app runs in needs its **own** redirect URI registered here, on that environment's exact `baseURL` host. Google only accepts a URL you have registered; anything else is a `redirect_uri_mismatch`.
54
+
55
+ This is the gotcha with feature-branch previews. A branch deployed to a Cloudflare preview URL — an ephemeral `*.workers.dev` or preview alias, not your registered `staging.<your-domain>` — is a host Google has never seen, so Google sign-in there fails. You cannot exercise Google sign-in from a preview URL until that exact URL is registered.
56
+
57
+ To test Google on a branch, either register that deployment's own `<its-url>/auth/callback/google` as an extra authorized redirect URI **and** point that deployment's `baseURL` at the same host, or run the flow against `dev` (localhost) or your registered `staging` environment instead.
58
+
59
+ Magic link and email OTP have no redirect URI — they work on any URL, preview or not. Only the OAuth providers need a registered callback.
60
+
61
+ ## 5. Mobile
62
+
63
+ You do **not** register a custom-scheme or deep-link URI in Google Console. Google only ever redirects to the Worker's `/auth/callback/google`. The Worker, not Google, is what hands control back to the app.
64
+
65
+ The mobile app passes its own deep link as the sign-in `callbackURL` — for example `myapp://auth/callback`. The Worker completes the OAuth exchange, then redirects to that deep link. For this to be allowed, the scheme must be listed in `trustedOrigins` (prefix match):
66
+
67
+ ```ts
68
+ auth({
69
+ trustedOrigins: ["myapp://", "https://app.example.com"],
70
+ });
71
+ ```
72
+
73
+ For native iOS or Android using the Google SDK's id-token flow (rather than the web redirect), register the platform-native **iOS** and **Android** OAuth client IDs in Google Console too, and pass all client IDs as an array:
74
+
75
+ ```ts
76
+ auth({
77
+ google: { clientId: ["<web-id>", "<ios-id>", "<android-id>"] },
78
+ });
79
+ ```
80
+
81
+ ## Where the credentials live
82
+
83
+ **Both credentials travel as one typed JSON secret.** The client id and client secret are stored together — through `@pithy-sh/secrets`, never committed, never an env literal — as `auth-google-credentials`:
84
+
85
+ ```
86
+ pithy secrets create auth-google-credentials --json '{"clientId":"<your-client-id>","clientSecret":"<your-client-secret>"}'
87
+ ```
88
+
89
+ The pair stays atomic: the client id never splits off into config. The package reads the whole credential from the secrets store at request time and wires it into the provider. Enable Google in config with `auth({ google: { enabled: true } })` — no credential values in the config.
90
+
91
+ ## Account linking
92
+
93
+ A user who signed up with a magic link and later signs in with Google is linked automatically when the verified emails match. Google is configured as a **trusted provider**, and the local email is already verified — the magic link proved it — so the two accounts merge into one rather than colliding.
94
+
95
+ `requireLocalEmailVerified` stays on. That is the secure default: it blocks account takeover where an attacker pre-registers an unverified row for a victim's email and waits for the victim's Google sign-in to link into it. Leave it on.
96
+
97
+ ## When the credential will not read
98
+
99
+ An enabled provider whose secret is missing, or whose stored value no longer matches its schema, costs
100
+ **that provider and nothing else**. Magic link, OTP, and every other provider keep signing people in.
101
+
102
+ A sign-in attempt with Google then answers `503` with the code `auth/provider_unavailable` and a
103
+ message naming google, rather than the `404 PROVIDER_NOT_FOUND` a provider nobody enabled gets — the two
104
+ are different facts and never share an answer. The attempt is recorded in the audit trail as
105
+ `auth/provider_unavailable`, which is where an operator learns a sign-in method is down. Fix it by
106
+ provisioning `auth-google-credentials` for that environment, or turn the provider off in config.
107
+
108
+ ## Checklist
109
+
110
+ - [ ] Google Cloud project created.
111
+ - [ ] OAuth consent screen: External, scopes `email` / `profile` / `openid`.
112
+ - [ ] Web OAuth client created; client ID and secret in hand.
113
+ - [ ] Redirect URI registered per environment, each as `<baseURL><basePath>/callback/google`.
114
+ - [ ] Redirect host matches each environment's `baseURL`.
115
+ - [ ] Mobile deep-link scheme listed in `trustedOrigins` (no deep link in Google Console).
116
+ - [ ] Native id-token flow: iOS/Android client IDs registered, all IDs passed as an array.
117
+ - [ ] `clientId` + `clientSecret` stored together via `pithy secrets create auth-google-credentials` (typed JSON); Google enabled in config with `google: { enabled: true }`.
118
+ - [ ] `requireLocalEmailVerified` left on.
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@pithy-sh/auth",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/pithy-sh/pithy.git",
8
+ "directory": "packages/auth"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "pithy.manifest.json",
13
+ "docs",
14
+ "!src/**/*.test.*"
15
+ ],
16
+ "type": "module",
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "exports": {
21
+ "./src/*": "./src/*.ts"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.json --noEmit false --outDir dist",
25
+ "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.client.json",
26
+ "test": "vitest run",
27
+ "test:node": "vitest run --project=node",
28
+ "test:workers": "vitest run --project=workers",
29
+ "test:integration": "vitest run --config vitest.integration.config.ts",
30
+ "clean": "rm -rf dist .turbo",
31
+ "reset": "bun run clean && rm -rf node_modules"
32
+ },
33
+ "dependencies": {
34
+ "@better-auth/i18n": "^1.7.1",
35
+ "@cloudflare/workers-types": "^5.20260729.1",
36
+ "@hono/zod-validator": "^0.9.0",
37
+ "@pithy-sh/core": "workspace:*",
38
+ "@pithy-sh/email": "workspace:*",
39
+ "@pithy-sh/secrets": "workspace:*",
40
+ "@pithy-sh/turnstile": "workspace:*",
41
+ "better-auth": "^1.7.1",
42
+ "hono": "^4.13.2",
43
+ "kysely": "^0.29.0",
44
+ "zod": "^4.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@cloudflare/vitest-plugin": "^1.0.0",
48
+ "@pithy-sh/cloudflare": "workspace:*",
49
+ "@pithy-sh/tsconfig": "workspace:*",
50
+ "@types/node": "^22.15.0",
51
+ "@vitest/coverage-v8": "^4.1.0",
52
+ "kysely-d1": "^0.4.0",
53
+ "miniflare": "^4.20260722.1",
54
+ "typescript": "^7.0.2",
55
+ "vitest": "^4.1.0",
56
+ "wrangler": "^4.115.0"
57
+ }
58
+ }