Skip to content

Commit aa06e72

Browse files
committed
Wire provider selection into codegen with just-in-time user provisioning
1 parent 0d133ef commit aa06e72

16 files changed

Lines changed: 419 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: "external: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: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { createClerkClient } from "@clerk/backend";
2+
import type {
3+
AuthenticateResult,
4+
AuthProvider,
5+
SupportsSessionRevocation,
6+
VerifiedSession,
7+
} from "wasp/server/auth/provider/types";
8+
9+
const clerk = createClerkClient({
10+
secretKey: process.env.CLERK_SECRET_KEY,
11+
publishableKey: process.env.CLERK_PUBLISHABLE_KEY,
12+
});
13+
14+
/**
15+
* Clerk, expressed as a Wasp `AuthProvider`.
16+
*
17+
* This app hand-writes the adapter and registers it with `customAuthProvider()`
18+
* -- the escape hatch for providers nobody has packaged yet. Compare `../clerk`,
19+
* where the same provider ships as an npm package instead. Clerk is by far the
20+
* least work to integrate: it contributes **no Prisma models and no routes**. It
21+
* only ever answers "whose request is this?".
22+
*
23+
* It is also the adapter that proves why session issuance is a separate
24+
* capability (`SupportsSessionIssuance`) rather than part of the base
25+
* interface. Clerk has **no
26+
* server-side password login at all** -- password verification lives on its
27+
* Frontend API behind a browser-held `__client` cookie, and its Backend API has
28+
* no endpoint that turns credentials into a session. So this object implements
29+
* `AuthProvider` and stops there. A uniform `login(email, password)` could only
30+
* be implemented for Clerk as something that throws or silently ignores its
31+
* arguments; a missing capability is the honest alternative.
32+
*/
33+
export const clerkAuthProvider: AuthProvider & SupportsSessionRevocation = {
34+
/**
35+
* Becomes `AuthIdentity.providerName`, so it must stay stable across deploys.
36+
*/
37+
id: "external:clerk",
38+
39+
/**
40+
* Wasp hands every adapter a standard web `Request` -- built from the HTTP
41+
* request, or synthesized with just an `Authorization` header for websocket
42+
* auth. Clerk's SDK consumes one natively, so there is nothing to convert.
43+
*
44+
* Clerk reads either its `__session` cookie or an `Authorization: Bearer`
45+
* header transparently, so the same code serves web and native clients.
46+
*
47+
* With `jwtKey` set this is local RS256 verification with no network call;
48+
* without it, Clerk fetches (and caches) the JWKS.
49+
*/
50+
async authenticate(request: Request): Promise<AuthenticateResult> {
51+
const requestState = await clerk.authenticateRequest(request, {
52+
jwtKey: process.env.CLERK_JWT_KEY,
53+
});
54+
55+
if (!requestState.isAuthenticated) {
56+
return { status: "unauthenticated" };
57+
}
58+
59+
const { userId, sessionId, sessionClaims } = requestState.toAuth();
60+
if (!userId || !sessionId) {
61+
return { status: "unauthenticated" };
62+
}
63+
64+
return {
65+
status: "authenticated",
66+
session: {
67+
sessionId,
68+
subjectId: userId,
69+
// The verified JWT's claims, recorded by Wasp when it provisions the
70+
// local user. NOTE: Clerk's default session token carries no email --
71+
// add one to the token template in the Clerk dashboard if the app's
72+
// user entity needs it at provisioning time.
73+
claims: sessionClaims as VerifiedSession["claims"],
74+
},
75+
};
76+
},
77+
78+
/**
79+
* Clerk sessions are revocable server-side, which is what lets `logout()` stay
80+
* uniform across all the example apps.
81+
*
82+
* Worth knowing: because Clerk's session tokens are short-lived JWTs verified
83+
* locally, revocation is not instantaneous -- an already-issued token stays
84+
* valid until it expires (~60s by default). Wasp's own auth revokes instantly.
85+
* Same API, weaker guarantee.
86+
*/
87+
async revokeSession(sessionId: string): Promise<void> {
88+
await clerk.sessions.revokeSession(sessionId);
89+
},
90+
};
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.
@@ -18,3 +21,12 @@ export {
1821
type SupportsSessionRevocation,
1922
type VerifiedSession,
2023
} from '@wasp.sh/auth-contract'
24+
25+
// PRIVATE API
26+
/**
27+
* The provider the developer registered via `app.auth.provider`, if any.
28+
*
29+
* Declared here so that a user-written adapter is type-checked against the
30+
* contract at build time rather than failing somewhere inside the session layer.
31+
*/
32+
export type RegisteredAuthProvider = FromRegister<'authProvider', AuthProvider>

0 commit comments

Comments
 (0)