Skip to content

Commit 4010708

Browse files
committed
Wire provider selection into codegen with just-in-time user provisioning
1 parent d4fccef commit 4010708

16 files changed

Lines changed: 413 additions & 44 deletions

File tree

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1-
import { action, app, page, query, route, waspAuth } from "@wasp.sh/spec";
1+
import {
2+
action,
3+
app,
4+
customAuthProvider,
5+
page,
6+
query,
7+
route,
8+
} from "@wasp.sh/spec";
9+
import { App } from "./src/App" with { type: "ref" };
210
import { MainPage } from "./src/MainPage" with { type: "ref" };
311
import { LoginPage } from "./src/auth/LoginPage" with { type: "ref" };
12+
import { clerkAuthProvider } from "./src/auth/provider" with { type: "ref" };
13+
import { clientEnvSchema } from "./src/env" with { type: "ref" };
414
import { createTask, getMyTasks } from "./src/operations" with { type: "ref" };
515

616
export default app({
@@ -11,22 +21,41 @@ export default app({
1121
auth: {
1222
userEntity: "User",
1323
onAuthFailedRedirectTo: "/login",
14-
// Wasp's own auth, selected explicitly. Everything that only makes sense
15-
// when Wasp runs signup and login -- methods, hooks, the success redirect --
16-
// lives inside waspAuth(), so none of it can leak into an app using an
17-
// external provider.
18-
provider: waspAuth({
19-
methods: {
20-
usernameAndPassword: {},
24+
// Clerk owns signup and login entirely; Wasp has no auth methods of its
25+
// own here, and the provider union makes them inexpressible.
26+
provider: customAuthProvider({
27+
id: "clerk",
28+
server: clerkAuthProvider,
29+
capabilities: ["session-revocation"],
30+
// Rendered into the generated env validation: a missing var fails at
31+
// boot with this explanation, not at the first authenticated request.
32+
env: {
33+
server: [
34+
{ name: "CLERK_SECRET_KEY", doc: "Clerk dashboard → API keys" },
35+
{ name: "CLERK_PUBLISHABLE_KEY", doc: "Clerk dashboard → API keys" },
36+
{
37+
name: "CLERK_JWT_KEY",
38+
optional: true,
39+
doc: "enables networkless JWT verification",
40+
},
41+
],
42+
client: [],
2143
},
22-
onAuthSucceededRedirectTo: "/",
2344
}),
2445
},
2546

47+
client: {
48+
// Wraps the app in Clerk's provider and bridges its token to Wasp's client.
49+
rootComponent: App,
50+
envValidationSchema: clientEnvSchema,
51+
},
52+
2653
spec: [
54+
// Identical to the other two apps.
2755
route("MainRoute", "/", page(MainPage, { authRequired: true })),
2856
route("LoginRoute", "/login", page(LoginPage)),
2957
query(getMyTasks, { entities: ["Task"], auth: true }),
3058
action(createTask, { entities: ["Task"], auth: true }),
59+
// Note: no api() declarations. Clerk contributes no routes and no tables.
3160
],
3261
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { ClerkProvider, useAuth as useClerkAuth } from "@clerk/clerk-react";
2+
import { useEffect } from "react";
3+
import { env } from "wasp/client";
4+
import { clearSessionId, setSessionId } from "wasp/client/api";
5+
6+
// Wasp's typed client env, declared in `clientEnvSchema` (see main.wasp.ts).
7+
// Using this rather than `import.meta.env` keeps the file type-checkable.
8+
const publishableKey = env.REACT_APP_CLERK_PUBLISHABLE_KEY;
9+
10+
/**
11+
* Bridges Clerk's session into Wasp's client.
12+
*
13+
* Wasp sends its credential as `Authorization: Bearer <token>`, so we hand it
14+
* Clerk's token whenever Clerk has one. This is the only Wasp-specific glue on
15+
* the client side, and it is the mirror image of what the Better Auth example's
16+
* login page does after a successful sign-in.
17+
*
18+
* Clerk's tokens are short-lived (~60s) and its SDK refreshes them on a timer,
19+
* so this effect re-runs and keeps Wasp's stored credential fresh.
20+
*/
21+
function ClerkToWaspSessionBridge({ children }: { children: React.ReactNode }) {
22+
const { isSignedIn, getToken } = useClerkAuth();
23+
24+
useEffect(() => {
25+
let cancelled = false;
26+
async function sync() {
27+
const token = isSignedIn ? await getToken() : null;
28+
if (!cancelled) {
29+
// Wasp stores the credential it attaches to every API call. Clearing it
30+
// on sign-out is what makes `logout()` uniform across providers.
31+
if (token) {
32+
setSessionId(token);
33+
} else {
34+
clearSessionId();
35+
}
36+
}
37+
}
38+
void sync();
39+
const interval = setInterval(() => void sync(), 30_000);
40+
return () => {
41+
cancelled = true;
42+
clearInterval(interval);
43+
};
44+
}, [isSignedIn, getToken]);
45+
46+
return <>{children}</>;
47+
}
48+
49+
export function App({ children }: { children: React.ReactNode }) {
50+
return (
51+
<ClerkProvider publishableKey={publishableKey}>
52+
<ClerkToWaspSessionBridge>{children}</ClerkToWaspSessionBridge>
53+
</ClerkProvider>
54+
);
55+
}
Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
1-
import { useState } from "react";
2-
import { LoginForm, SignupForm } from "wasp/client/auth";
1+
import { SignIn } from "@clerk/clerk-react";
32

43
/**
5-
* Identical to `wasp-auth/`'s login page. This is the file that changes when
6-
* the app adopts its real auth provider -- everything else stays put.
4+
* The Clerk login page, and the clearest illustration of where Wasp's uniform
5+
* line falls.
6+
*
7+
* There is no `<LoginForm />` here and there cannot be one. Clerk has no
8+
* server-side password endpoint, so Wasp cannot post credentials on the app's
9+
* behalf -- the browser must talk to Clerk's Frontend API directly. Clerk's own
10+
* component does that.
11+
*
12+
* Compare `../../../wasp-auth/src/auth/LoginPage.tsx` and
13+
* `../../../better-auth/src/auth/LoginPage.tsx`. The login pages differ per
14+
* provider. Everything else in these apps does not.
715
*/
816
export function LoginPage() {
9-
const [isSignup, setIsSignup] = useState(false);
10-
1117
return (
12-
<main
13-
style={{ maxWidth: 380, margin: "3rem auto", fontFamily: "system-ui" }}
14-
>
15-
<h1>{isSignup ? "Sign up" : "Log in"}</h1>
16-
{isSignup ? <SignupForm /> : <LoginForm />}
17-
<button onClick={() => setIsSignup((v) => !v)}>
18-
{isSignup ? "I already have an account" : "I need an account"}
19-
</button>
18+
<main style={{ display: "grid", placeItems: "center", marginTop: "3rem" }}>
19+
<SignIn />
2020
</main>
2121
);
2222
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { createClerkClient } from "@clerk/backend";
2+
import type {
3+
AuthProvider,
4+
VerifiedSession,
5+
} from "wasp/server/auth/provider/types";
6+
7+
const clerk = createClerkClient({
8+
secretKey: process.env.CLERK_SECRET_KEY,
9+
publishableKey: process.env.CLERK_PUBLISHABLE_KEY,
10+
});
11+
12+
/**
13+
* Clerk, expressed as a Wasp `AuthProvider`.
14+
*
15+
* This app hand-writes the adapter and registers it with `customAuthProvider()`
16+
* -- the escape hatch for providers nobody has packaged yet. Compare `../clerk`,
17+
* where the same provider ships as an npm package instead. Clerk is by far the
18+
* least work to integrate: it contributes **no Prisma models and no routes**. It
19+
* only ever answers "whose request is this?".
20+
*
21+
* It is also the adapter that proves why `AuthProvider` and
22+
* `SessionIssuingAuthProvider` are separate interfaces. Clerk has **no
23+
* server-side password login at all** -- password verification lives on its
24+
* Frontend API behind a browser-held `__client` cookie, and its Backend API has
25+
* no endpoint that turns credentials into a session. So this object implements
26+
* `AuthProvider` and stops there. A uniform `login(email, password)` could only
27+
* be implemented for Clerk as something that throws or silently ignores its
28+
* arguments; a missing capability is the honest alternative.
29+
*/
30+
export const clerkAuthProvider: AuthProvider = {
31+
/**
32+
* Becomes `AuthIdentity.providerName`, so it must stay stable across deploys.
33+
*/
34+
id: "clerk",
35+
36+
/**
37+
* Wasp hands every adapter a standard web `Request` -- built from the HTTP
38+
* request, or synthesized with just an `Authorization` header for websocket
39+
* auth. Clerk's SDK consumes one natively, so there is nothing to convert.
40+
*
41+
* Clerk reads either its `__session` cookie or an `Authorization: Bearer`
42+
* header transparently, so the same code serves web and native clients.
43+
*
44+
* With `jwtKey` set this is local RS256 verification with no network call;
45+
* without it, Clerk fetches (and caches) the JWKS.
46+
*/
47+
async authenticate(request: Request): Promise<VerifiedSession | null> {
48+
const requestState = await clerk.authenticateRequest(request, {
49+
jwtKey: process.env.CLERK_JWT_KEY,
50+
});
51+
52+
if (!requestState.isAuthenticated) {
53+
return null;
54+
}
55+
56+
const { userId, sessionId, sessionClaims } = requestState.toAuth();
57+
if (!userId || !sessionId) {
58+
return null;
59+
}
60+
61+
return {
62+
sessionId,
63+
subjectId: userId,
64+
// The verified JWT's claims, recorded by Wasp when it provisions the
65+
// local user. NOTE: Clerk's default session token carries no email --
66+
// add one to the token template in the Clerk dashboard if the app's
67+
// user entity needs it at provisioning time.
68+
claims: sessionClaims as VerifiedSession["claims"],
69+
};
70+
},
71+
72+
/**
73+
* Clerk sessions are revocable server-side, which is what lets `logout()` stay
74+
* uniform across all the example apps.
75+
*
76+
* Worth knowing: because Clerk's session tokens are short-lived JWTs verified
77+
* locally, revocation is not instantaneous -- an already-issued token stays
78+
* valid until it expires (~60s by default). Wasp's own auth revokes instantly.
79+
* Same API, weaker guarantee.
80+
*/
81+
async revokeSession(sessionId: string): Promise<void> {
82+
await clerk.sessions.revokeSession(sessionId);
83+
},
84+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import * as z from "zod";
2+
3+
/**
4+
* Clerk's publishable key has to reach the browser, so it goes through Wasp's
5+
* client env schema rather than `import.meta.env` -- that keeps it typed and
6+
* validated at startup instead of failing at render time.
7+
*/
8+
export const clientEnvSchema = z.object({
9+
REACT_APP_CLERK_PUBLISHABLE_KEY: z.string().min(1),
10+
});

waspc/data/Generator/templates/sdk/wasp/auth/user.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,12 @@ function makeAuthUser(data: AuthUserData): AuthUser {
125125
return {
126126
...data,
127127
getFirstProviderUserId: () => {
128-
const identities = Object.values(data.identities).filter(isNotNull);
128+
// The cast keeps this working when `identities` is empty, which is the case
129+
// for an app using a custom auth provider and no Wasp auth methods: the
130+
// generated type is then `{}` and `Object.values` would yield `unknown[]`.
131+
const identities = Object.values(
132+
data.identities as Record<string, { id: string } | null>
133+
).filter(isNotNull);
129134
return identities.length > 0 ? identities[0].id : null;
130135
},
131136
};
Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
import { waspAuthProvider } from './wasp.js'
1+
{{={= =}=}}
22
import { type AuthProvider } from './types.js'
3+
{=# isCustomAuthProviderUsed =}
4+
{=& authProvider.importStatement =}
5+
{=/ isCustomAuthProviderUsed =}
6+
{=^ isCustomAuthProviderUsed =}
7+
import { waspAuthProvider } from './wasp.js'
8+
{=/ isCustomAuthProviderUsed =}
39

410
// PRIVATE API
511
export {
@@ -13,9 +19,19 @@ export {
1319
/**
1420
* The auth provider this app runs on.
1521
*
16-
* There is exactly one today and it is not configurable. This module exists so that
17-
* everything else in Wasp depends on the `AuthProvider` interface rather than on a
18-
* concrete implementation -- making the provider selectable is a later, additive
19-
* change that will not touch any of its consumers.
22+
* Everything else in Wasp depends on the `AuthProvider` interface rather than on
23+
* a concrete implementation, so selecting a different one here is the only change
24+
* needed to authenticate against something other than Wasp's own auth.
25+
*/
26+
export const authProvider: AuthProvider =
27+
{=# isCustomAuthProviderUsed =}{= authProvider.importIdentifier =}{=/ isCustomAuthProviderUsed =}{=^ isCustomAuthProviderUsed =}waspAuthProvider{=/ isCustomAuthProviderUsed =}
28+
29+
// PRIVATE API
30+
/**
31+
* Whether the provider owns Wasp's auth entity.
32+
*
33+
* Wasp's own auth writes the `Auth` table itself, so a subject id from it already
34+
* identifies a local row. An external provider's subject id is foreign, and Wasp
35+
* has to resolve it to a local user -- provisioning one on first sight.
2036
*/
21-
export const authProvider: AuthProvider = waspAuthProvider
37+
export const providerOwnsAuthEntity: boolean = {=# isCustomAuthProviderUsed =}false{=/ isCustomAuthProviderUsed =}{=^ isCustomAuthProviderUsed =}true{=/ isCustomAuthProviderUsed =}

waspc/data/Generator/templates/sdk/wasp/server/auth/provider/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { type AuthProvider } from '@wasp.sh/auth-contract'
2+
import { type FromRegister } from '../../../types/register.js'
3+
14
// PRIVATE API
25
/**
36
* The contract between Wasp and an authentication provider.
@@ -13,3 +16,12 @@ export {
1316
type SessionIssuingAuthProvider,
1417
type VerifiedSession,
1518
} from '@wasp.sh/auth-contract'
19+
20+
// PRIVATE API
21+
/**
22+
* The provider the developer registered via `app.auth.provider`, if any.
23+
*
24+
* Declared here so that a user-written adapter is type-checked against the
25+
* contract at build time rather than failing somewhere inside the session layer.
26+
*/
27+
export type RegisteredAuthProvider = FromRegister<'authProvider', AuthProvider>

0 commit comments

Comments
 (0)