Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions examples/auth-providers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,40 @@ provider? The answer, demonstrated rather than asserted, is _everything except t
| `clerk/` | Clerk, hosted | A provider with no server-side login at all, and no schema of its own |
| `custom-clerk/` | Clerk, hand-written in-app | The `customAuthProvider()` escape hatch — no adapter package needed |

All four start as clones of `wasp-auth/` running Wasp's own auth. Each app diverges only
when its provider arrives later in this PR stack, so every diff in these apps from here
on is auth-relevant.
## The part that is identical in all of them

```ts
// src/operations.ts — byte-for-byte the same in every app
export const getMyTasks: GetMyTasks<void, Task[]> = async (_args, context) => {
if (!context.user) throw new HttpError(401);
return context.entities.Task.findMany({ where: { userId: context.user.id } });
};
```

`context.user` is a row in the app's own `User` table in every app, with the app's own id type.
It is never Clerk's `user_2abc…` string. That is the invariant the provider interface exists to
protect, and it is the one RedwoodJS did not hold: it shipped nine auth adapters over a single
interface but left provisioning to the developer, so `currentUser.id` ended up meaning different
things depending on which adapter was installed.

Also identical: `authRequired` on pages, `auth: true` on operations, `useAuth()`, and `logout()`.

## The part that differs

Only how a session is _established_:

- `wasp-auth` and `better-auth` render login forms and post credentials to the server.
- `clerk` and `custom-clerk` cannot. Clerk has no server-side password endpoint — verification
lives on its Frontend API behind a browser-held cookie — so those apps use Clerk's own React
components and Wasp only ever verifies the resulting token.

That asymmetry is why session issuance is a capability (`SupportsSessionIssuance`) layered on
the base `AuthProvider` (verify a request) rather than part of it. Clerk carries only the base
plus revocation.

`clerk/` and `custom-clerk/` are the same provider integrated two ways: through the
`@wasp.sh/auth-clerk` package, and hand-written in the app via `customAuthProvider()`. Diffing
them shows exactly what an adapter package absorbs.

## Running them

Expand All @@ -24,4 +55,4 @@ Each app is a normal Wasp app:
cd wasp-auth && wasp db migrate-dev && wasp start
```

See each app's own README for provider-specific setup.
`better-auth`, `clerk` and `custom-clerk` need environment variables — see each app's README.
93 changes: 87 additions & 6 deletions examples/auth-providers/better-auth/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,91 @@
# Auth providers — Better Auth

For now a byte-for-byte clone of `../wasp-auth` running Wasp's own auth. It switches to
Better Auth via the `@wasp.sh/auth-better-auth` adapter package later in this PR stack.
Wasp authenticates every request through **Better Auth**, an in-process auth library that owns
its own tables and its own HTTP endpoints, via the `@wasp.sh/auth-better-auth` adapter package
(`../packages/auth-better-auth`).

Two things are already in place so that the switch shows up as a pure auth diff:
```ts
import { betterAuth } from "@wasp.sh/auth-better-auth/spec";

- the `better-auth` dependency is installed, and
- `schema.prisma` carries Better Auth's own tables (`BetterAuth*`). They are plain
Prisma models and sit unused until the provider arrives.
auth: {
userEntity: "User",
onAuthFailedRedirectTo: "/login",
provider: betterAuth({ setupFn: setupBetterAuth }), // Better Auth verifies instead
}
```

That one call carries the whole server-side integration: the adapter, Better Auth's own routes
(mounted at `/better-auth` with the JSON body parser stripped), and the `BETTER_AUTH_SECRET`
requirement. What the manifest cannot carry is the Prisma schema -- the four `BetterAuth*`
models still live in this app's `schema.prisma`.

## What is identical to the other apps

`schema.prisma` (the `User` and `Task` models), `src/operations.ts` and `src/MainPage.tsx` are
byte-for-byte the same as in `../wasp-auth` and `../clerk`. So are `authRequired`, `auth: true`,
`useAuth()` and `logout()`.

## What is specific to this app

- `provider: betterAuth(...)` in `main.wasp.ts` — the adapter lives in
`@wasp.sh/auth-better-auth` (`../packages/auth-better-auth`): the Better Auth instance, the
provider's two methods, and the route handler, all built by one `createServerAdapter` factory.
- The manifest's `routes` declaration — Better Auth's own endpoints, mounted by Wasp at
`/better-auth`. An earlier version of this app declared them by hand with `api("ALL", ...)`
plus an `apiNamespace` middleware tweak; the manifest replaces both.
- `src/auth/authClient.ts` — two lines: point the package's client at the Wasp server.
- `src/auth/LoginPage.tsx` — uses Better Auth's client, because Wasp does not wrap login.
- Four `BetterAuth*` models in `schema.prisma`.

## What this example is really demonstrating

**An in-process provider is harder to adopt than a hosted one**, which is the opposite of the
intuition. Compare `../clerk`, which adds _zero_ tables. Better Auth owns its storage, and the
adapter package can absorb most but not all of that:

- absorbed: every model needs `modelName` set, or `user`/`session`/`account` collide with
Wasp's tables (and it must be the _Prisma client property_, not the `@@map` name — the
adapter does a raw `db[modelName]` lookup). The package sets these.
- absorbed: its routes need the JSON body parser removed, because `toNodeHandler` reads the
raw stream. Get this wrong and requests hang with no error. The manifest's
`routes: { rawBody: true }` handles it.
- not absorbable: its four tables live in this app's Prisma schema next to Wasp's own, pasted
from the package's README. A manifest cannot contribute Prisma models.

That is worth knowing before betting on Better Auth: the interface is easiest for the provider
Wasp cares least about and hardest for the one actually motivating the work.

## Run it

```sh
printf 'BETTER_AUTH_SECRET=better-auth-example-secret-0123456789abcdef\n' > .env.server
wasp db migrate-dev
wasp start
```

## Verified

```
POST /better-auth/sign-up/email 200 {"token":"ISUjd4GE…","user":{…}}
POST /better-auth/sign-in/email 200 {"token":"BmzL9Xds…"}
GET /auth/me (Bearer BA token) 200 {"id":"51666a16-…","identities":{}}
POST /operations/create-task 200 task.userId = 51666a16-…
POST /operations/get-my-tasks 200 the task
POST /operations/get-my-tasks (no token) 401
```

Database afterwards — one Wasp `User`, one Better Auth user, linked by an `AuthIdentity`:

```
wasp Users: 1 | BA users: 1
[{ providerName: "external:better-auth",
providerUserId: "Y4jJpb9OoIPjH9n3ZGQ9s61i0FHggjkq",
authId: "39cdb26c-…" }]
```

**`/auth/me` returns `51666a16-…`, a uuid from this app's own `User` table — not Better Auth's
`Y4jJpb9O…`.** Nobody wrote code to make that happen; Wasp provisioned the local row the first
time it saw that subject. That is the invariant RedwoodJS did not hold.

`identities` is `{}` because no Wasp auth methods are enabled. That is the honest outcome of
`identities` being tiered rather than uniform: its key set depends on the provider.
12 changes: 4 additions & 8 deletions examples/auth-providers/better-auth/main.wasp.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { action, app, page, query, route, waspAuth } from "@wasp.sh/spec";
import { betterAuth } from "@wasp.sh/auth-better-auth/spec";
import { action, app, page, query, route } from "@wasp.sh/spec";
import { MainPage } from "./src/MainPage" with { type: "ref" };
import { LoginPage } from "./src/auth/LoginPage" with { type: "ref" };
import { setupBetterAuth } from "./src/auth/setup" with { type: "ref" };
import { createTask, getMyTasks } from "./src/operations" with { type: "ref" };

export default app({
Expand All @@ -11,13 +13,7 @@ export default app({
auth: {
userEntity: "User",
onAuthFailedRedirectTo: "/login",
// TODO: to be replaced with the Better Auth adapter.
provider: waspAuth({
methods: {
usernameAndPassword: {},
},
onAuthSucceededRedirectTo: "/",
}),
provider: betterAuth({ setupFn: setupBetterAuth }),
},

spec: [
Expand Down
80 changes: 21 additions & 59 deletions examples/auth-providers/better-auth/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions examples/auth-providers/better-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
".wasp/out/sdk/wasp"
],
"dependencies": {
"@wasp.sh/auth-better-auth": "file:../packages/auth-better-auth",
"better-auth": "1.6.25",
"react": "^19.2.1",
"react-dom": "^19.2.1",
Expand Down
58 changes: 54 additions & 4 deletions examples/auth-providers/better-auth/src/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,69 @@
import { useState } from "react";
import { LoginForm, SignupForm } from "wasp/client/auth";
import { authClient } from "./authClient";

/**
* Identical to `wasp-auth/`'s login page. This is the file that changes when
* the app adopts its real auth provider -- everything else stays put.
* The only meaningfully different file between the three example apps.
*
* Better Auth CAN mint a session server-side, so this page posts credentials and
* gets a token back. Compare the Clerk example, where that is impossible.
*
* The token is then handed to Wasp's own client storage so that every subsequent
* Wasp API call carries it -- that hand-off is the only Wasp-specific line here.
*/
export function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSignup, setIsSignup] = useState(false);

async function submit(e: React.FormEvent) {
e.preventDefault();
setError(null);

const result = isSignup
? await authClient.signUp.email({ email, password, name: email })
: await authClient.signIn.email({ email, password });

if (result.error) {
setError(result.error.message ?? "Something went wrong");
return;
}

const token = result.data?.token;
if (!token) {
setError("No session token returned");
return;
}

// Hand Better Auth's token to Wasp's client so `useAuth()` and every
// operation call pick it up.
const { setSessionId } = await import("wasp/client/api");
setSessionId(token);
window.location.href = "/";
}

return (
<main
style={{ maxWidth: 380, margin: "3rem auto", fontFamily: "system-ui" }}
>
<h1>{isSignup ? "Sign up" : "Log in"}</h1>
{isSignup ? <SignupForm /> : <LoginForm />}
<p style={{ color: "#666" }}>Powered by Better Auth</p>
<form onSubmit={submit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="password"
/>
<button type="submit">{isSignup ? "Sign up" : "Log in"}</button>
</form>
{error ? <p style={{ color: "crimson" }}>{error}</p> : null}
<button onClick={() => setIsSignup((v) => !v)}>
{isSignup ? "I already have an account" : "I need an account"}
</button>
Expand Down
Loading
Loading