| 1 | --- |
| 2 | title: Custom OAuth and OIDC |
| 3 | description: Add Keycloak OIDC sign-in to an Alchemy Worker with D1, one small step at a time. |
| 4 | --- |
| 5 | |
| 6 | Start in an existing Alchemy project with [Bun](https://bun.sh) and a [configured Cloudflare profile](/cloudflare/setup). This standalone walkthrough uses Keycloak and Better Auth 1.7.5's `genericOAuth`; all application files are introduced below. |
| 7 | |
| 8 | ## Add the auth dependencies |
| 9 | |
| 10 | ```sh |
| 11 | bun add @alchemy.run/better-auth better-auth@1.7.5 kysely |
| 12 | ``` |
| 13 | |
| 14 | Use an `@alchemy.run/better-auth` release matching your installed Alchemy release. Keep your existing `alchemy` and `effect` dependencies. |
| 15 | |
| 16 | ## Create the Auth service |
| 17 | |
| 18 | Create `src/auth.ts`: |
| 19 | |
| 20 | ```typescript |
| 21 | import { BetterAuth } from "@alchemy.run/better-auth"; |
| 22 | import * as Config from "effect/Config"; |
| 23 | import * as Context from "effect/Context"; |
| 24 | import * as Effect from "effect/Effect"; |
| 25 | import * as Layer from "effect/Layer"; |
| 26 | import * as Option from "effect/Option"; |
| 27 | |
| 28 | const makeAuth = Effect.gen(function* () { |
| 29 | const baseURL = yield* Config.String("AUTH_BASE_URL").pipe(Config.option); |
| 30 | return yield* BetterAuth({ |
| 31 | basePath: "/api/auth", |
| 32 | baseURL: Option.getOrUndefined(baseURL), |
| 33 | account: { accountLinking: { disableImplicitLinking: true } }, |
| 34 | }); |
| 35 | }); |
| 36 | |
| 37 | export class Auth extends Context.Service< |
| 38 | Auth, |
| 39 | Effect.Success<typeof makeAuth> |
| 40 | >()("app/Auth") { |
| 41 | static readonly layer = Layer.effect(Auth, makeAuth); |
| 42 | } |
| 43 | ``` |
| 44 | |
| 45 | The service keeps its options directly inside `BetterAuth` and reads Config during construction. Leaving `AUTH_BASE_URL` unset lets Better Auth use the incoming request's origin. |
| 46 | |
| 47 | ## Start with OIDC disabled |
| 48 | |
| 49 | Apply this addition to `src/auth.ts`: |
| 50 | |
| 51 | ```diff lang="typescript" |
| 52 | const makeAuth = Effect.gen(function* () { |
| 53 | const baseURL = yield* Config.String("AUTH_BASE_URL").pipe(Config.option); |
| 54 | + const enabled = yield* Config.Boolean("OIDC_ENABLED").pipe( |
| 55 | + Config.withDefault(false), |
| 56 | + ); |
| 57 | return yield* BetterAuth({ |
| 58 | ``` |
| 59 | |
| 60 | OIDC stays disabled until you know the local URL and register its callback. The next addition reads credentials and installs the plugin only when this switch is enabled. |
| 61 | |
| 62 | ## Register the OIDC provider |
| 63 | |
| 64 | Apply both hunks to `src/auth.ts`, adding the imports and the inline plugin configuration: |
| 65 | |
| 66 | ```diff lang="typescript" |
| 67 | import * as Option from "effect/Option"; |
| 68 | +import * as Redacted from "effect/Redacted"; |
| 69 | +import { genericOAuth } from "better-auth/plugins/generic-oauth"; |
| 70 | @@ |
| 71 | baseURL: Option.getOrUndefined(baseURL), |
| 72 | + plugins: enabled ? [genericOAuth({ |
| 73 | + config: [{ |
| 74 | + providerId: "company", |
| 75 | + clientId: yield* Config.String("OIDC_CLIENT_ID"), |
| 76 | + clientSecret: Redacted.value(yield* Config.Redacted("OIDC_CLIENT_SECRET")), |
| 77 | + discoveryUrl: yield* Config.String("OIDC_DISCOVERY_URL"), |
| 78 | + scopes: ["openid", "profile", "email"], |
| 79 | + pkce: true, |
| 80 | + authentication: "basic", |
| 81 | + requireIdTokenVerification: true, |
| 82 | + requireEmailVerification: true, |
| 83 | + disableProviderLogout: true, |
| 84 | + }], |
| 85 | + })] : [], |
| 86 | account: { accountLinking: { disableImplicitLinking: true } }, |
| 87 | ``` |
| 88 | |
| 89 | In 1.7.5, `genericOAuth` registers a social provider named `company`, using `signIn.social` and `/api/auth/callback/company`, not the older `signIn.oauth2` flow. HTTP Basic client authentication matches Keycloak's client-secret token exchange, while required ID-token verification fails closed when discovery lacks usable verification metadata. |
| 90 | |
| 91 | ## Keep application logout local |
| 92 | |
| 93 | `disableProviderLogout: true` makes sign-out end the application session without terminating a shared Keycloak SSO session. Keep it unless you deliberately configure provider-wide logout and its registered return URLs. |
| 94 | |
| 95 | ## Declare the database |
| 96 | |
| 97 | Create `src/database.ts`: |
| 98 | |
| 99 | ```typescript |
| 100 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 101 | |
| 102 | export const AuthDb = Cloudflare.D1.Database("AuthDb"); |
| 103 | ``` |
| 104 | |
| 105 | This declares the D1 database that will store users, accounts, and sessions. The same resource uses local D1 during `alchemy dev` and Cloudflare D1 when deployed. |
| 106 | |
| 107 | ## Give Auth its database layer |
| 108 | |
| 109 | Create `src/auth-layer.ts`: |
| 110 | |
| 111 | ```typescript |
| 112 | import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; |
| 113 | import * as Layer from "effect/Layer"; |
| 114 | import { Auth } from "./auth.ts"; |
| 115 | import { AuthDb } from "./database.ts"; |
| 116 | |
| 117 | export const AuthLive = Auth.layer.pipe( |
| 118 | Layer.provide(CloudflareD1(AuthDb)), |
| 119 | ); |
| 120 | ``` |
| 121 | |
| 122 | `CloudflareD1` supplies the database binding and automatic supported schema migrations. Alchemy also provisions the stable signing secret automatically; do not invent a manual secret for this example. |
| 123 | |
| 124 | ## Host Auth in a Worker |
| 125 | |
| 126 | Create `src/worker.ts`: |
| 127 | |
| 128 | ```typescript |
| 129 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 130 | import * as Effect from "effect/Effect"; |
| 131 | import { Auth } from "./auth.ts"; |
| 132 | import { AuthLive } from "./auth-layer.ts"; |
| 133 | |
| 134 | export default class AuthWorker extends Cloudflare.Worker<AuthWorker>()( |
| 135 | "AuthWorker", |
| 136 | { |
| 137 | main: import.meta.url, |
| 138 | compatibility: { flags: ["nodejs_compat"] }, |
| 139 | assets: { directory: "./public", runWorkerFirst: ["/api/*"] }, |
| 140 | }, |
| 141 | Effect.gen(function* () { |
| 142 | const auth = yield* Auth; |
| 143 | return { fetch: auth.fetch }; |
| 144 | }).pipe(Effect.provide(AuthLive)), |
| 145 | ) {} |
| 146 | ``` |
| 147 | |
| 148 | The Worker forwards API requests to Better Auth, including its session and callback endpoints. Static assets serve the browser page outside `/api/*`. |
| 149 | |
| 150 | ## Define the stack |
| 151 | |
| 152 | Use this as `alchemy.run.ts` for the walkthrough: |
| 153 | |
| 154 | ```typescript |
| 155 | import * as Alchemy from "alchemy"; |
| 156 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 157 | import * as Effect from "effect/Effect"; |
| 158 | import AuthWorker from "./src/worker.ts"; |
| 159 | |
| 160 | export default Alchemy.Stack( |
| 161 | "CompanyAuth", |
| 162 | { providers: Cloudflare.providers(), state: Alchemy.localState() }, |
| 163 | Effect.gen(function* () { |
| 164 | const worker = yield* AuthWorker; |
| 165 | return { url: worker.url }; |
| 166 | }), |
| 167 | ); |
| 168 | ``` |
| 169 | |
| 170 | The output exposes the Worker's actual URL. Preserve the local state directory so subsequent runs retain resource identities and the signing secret. |
| 171 | |
| 172 | ## Add the browser page |
| 173 | |
| 174 | Create `public/index.html`: |
| 175 | |
| 176 | ```html |
| 177 | <!doctype html> |
| 178 | <html lang="en"> |
| 179 | <head> |
| 180 | <meta charset="utf-8"> |
| 181 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 182 | <title>Company sign-in</title> |
| 183 | </head> |
| 184 | <body> |
| 185 | <h1>Company sign-in</h1> |
| 186 | <button id="sign-in" type="button">Sign in with company</button> |
| 187 | <button id="read-session" type="button">Read session</button> |
| 188 | <button id="sign-out" type="button">Sign out</button> |
| 189 | <p id="status" role="status"></p> |
| 190 | <script type="module" src="/client.js"></script> |
| 191 | </body> |
| 192 | </html> |
| 193 | ``` |
| 194 | |
| 195 | The page provides three controls and a text-only status area. The browser bundle will be written to `public/client.js`. |
| 196 | |
| 197 | ## Create the browser client |
| 198 | |
| 199 | Create `src/client.ts`: |
| 200 | |
| 201 | ```typescript |
| 202 | import { createAuthClient } from "better-auth/client"; |
| 203 | |
| 204 | const authClient = createAuthClient(); |
| 205 | const status = document.getElementById("status")!; |
| 206 | |
| 207 | async function run(action: () => Promise<string>) { |
| 208 | try { |
| 209 | status.textContent = await action(); |
| 210 | } catch { |
| 211 | status.textContent = "Could not reach the server. Try again."; |
| 212 | } |
| 213 | } |
| 214 | ``` |
| 215 | |
| 216 | The client uses the page's origin and the default `/api/auth` path; no `genericOAuthClient` plugin is needed in 1.7.5. It imports no server Auth module or credentials. |
| 217 | |
| 218 | ## Start company sign-in |
| 219 | |
| 220 | Append to `src/client.ts`: |
| 221 | |
| 222 | ```typescript |
| 223 | document.getElementById("sign-in")!.onclick = () => void run(async () => { |
| 224 | const { data, error } = await authClient.signIn.social({ |
| 225 | provider: "company", |
| 226 | callbackURL: "/", |
| 227 | disableRedirect: true, |
| 228 | }); |
| 229 | if (error) return "Could not start company sign-in. Check the configuration."; |
| 230 | if (!data?.url) return "No authorization URL was returned."; |
| 231 | window.location.assign(data.url); |
| 232 | return "Redirecting to company sign-in…"; |
| 233 | }); |
| 234 | ``` |
| 235 | |
| 236 | The provider ID matches the server's `company` registration. Better Auth returns the authorization URL and sends successful callbacks back to `/`. |
| 237 | |
| 238 | ## Read the current session |
| 239 | |
| 240 | Append to `src/client.ts`: |
| 241 | |
| 242 | ```typescript |
| 243 | async function readSession(): Promise<string> { |
| 244 | const { data, error } = await authClient.getSession(); |
| 245 | if (error) return "Could not check your session. Try again."; |
| 246 | return data ? `Signed in as ${data.user.name}.` : "Not signed in."; |
| 247 | } |
| 248 | |
| 249 | document.getElementById("read-session")!.onclick = () => void run(readSession); |
| 250 | void run(readSession); |
| 251 | ``` |
| 252 | |
| 253 | The page checks the session on load, including after the OAuth callback. The button repeats that lookup without confusing a failed request with an anonymous session. |
| 254 | |
| 255 | ## Sign out |
| 256 | |
| 257 | Append to `src/client.ts`: |
| 258 | |
| 259 | ```typescript |
| 260 | document.getElementById("sign-out")!.onclick = () => void run(async () => { |
| 261 | const { error } = await authClient.signOut(); |
| 262 | return error ? "Could not sign out. Try again." : "Signed out."; |
| 263 | }); |
| 264 | ``` |
| 265 | |
| 266 | This ends the application session. Because provider logout is disabled, a subsequent sign-in may reuse the user's existing Keycloak SSO session. |
| 267 | |
| 268 | ## Bundle the browser client |
| 269 | |
| 270 | ```sh |
| 271 | bun build ./src/client.ts --target browser --outdir ./public |
| 272 | ``` |
| 273 | |
| 274 | The final client is the initial `src/client.ts` block followed by the three append blocks, in order. The final `src/auth.ts` is its initial module with both labeled diffs applied; every other module above is complete as shown. |
| 275 | |
| 276 | ## Discover the local origin |
| 277 | |
| 278 | ```sh |
| 279 | bunx alchemy dev --stage company-guide |
| 280 | ``` |
| 281 | |
| 282 | Run without provider credentials or `OIDC_ENABLED=true` for this first start; Alchemy allocates a port and prints the actual origin. Keep OIDC disabled until the registration below is ready, and rebuild the browser bundle whenever you edit `src/client.ts`. |
| 283 | |
| 284 | ## Create the Keycloak client |
| 285 | |
| 286 | In Keycloak, select your application realm rather than the administration realm, open **Clients → Create client**, choose **OpenID Connect**, and set a client ID such as `alchemy-auth`. Enable **Client authentication** and **Standard flow**, leaving implicit flow and direct access grants disabled. |
| 287 | |
| 288 | ## Register the callback |
| 289 | |
| 290 | ```text |
| 291 | /api/auth/callback/company |
| 292 | ``` |
| 293 | |
| 294 | In **Login settings**, set the home URL to the printed origin and add that origin plus this path to **Valid redirect URIs**, without wildcards. Save the client and copy its secret from **Credentials** for the server-side token exchange. |
| 295 | |
| 296 | ## Configure identity claims |
| 297 | |
| 298 | Require S256 PKCE in the client's advanced settings where available, and ensure the `profile` and `email` scopes supply `sub`, `name`, `email`, and `email_verified`. Configure real realm email delivery and verification, and use verified realm users rather than marking arbitrary addresses verified. |
| 299 | |
| 300 | Keycloak's [server administration guide](https://www.keycloak.org/docs/latest/server_admin/#_oidc_clients) covers client configuration. Its [OIDC guide](https://www.keycloak.org/securing-apps/oidc-layers) describes discovery endpoints. |
| 301 | |
| 302 | ## Supply server credentials |
| 303 | |
| 304 | Create `.env.local.example`, then copy it to a gitignored `.env.local` and replace the placeholder values: |
| 305 | |
| 306 | ```dotenv |
| 307 | OIDC_ENABLED=true |
| 308 | OIDC_CLIENT_ID=alchemy-auth |
| 309 | OIDC_CLIENT_SECRET=replace-with-your-client-secret |
| 310 | OIDC_DISCOVERY_URL=https://identity.example.com/realms/my-app/.well-known/openid-configuration |
| 311 | # Leave AUTH_BASE_URL unset for local request-origin discovery. |
| 312 | ``` |
| 313 | |
| 314 | Discovery must be reachable from the Worker and publish the expected issuer, authorization/token endpoints, signing algorithms, and `jwks_uri`; use the application realm's real URL. These server-side Config values must stay out of `VITE_*`, browser bundles, source control, and logs. |
| 315 | |
| 316 | ## Enable OIDC locally |
| 317 | |
| 318 | ```sh |
| 319 | bunx alchemy dev --stage company-guide --env-file .env.local |
| 320 | ``` |
| 321 | |
| 322 | Stop the first dev process before running this command, and update Keycloak's callback if the newly printed origin changes. Open that origin, select **Sign in with company**, then use **Read session** and **Sign out** to exercise the roundtrip. |
| 323 | |
| 324 | ## Set the production origin |
| 325 | |
| 326 | Create `.env.production.example` as a template for your private production configuration: |
| 327 | |
| 328 | ```dotenv |
| 329 | AUTH_BASE_URL=https://auth.example.com |
| 330 | OIDC_ENABLED=true |
| 331 | OIDC_CLIENT_ID=replace-with-production-client-id |
| 332 | OIDC_CLIENT_SECRET=replace-with-production-client-secret |
| 333 | OIDC_DISCOVERY_URL=https://identity.example.com/realms/production/.well-known/openid-configuration |
| 334 | ``` |
| 335 | |
| 336 | Replace the example origin with the actual HTTPS Worker origin, without a trailing slash or `/api/auth`; `baseURL` does not attach a domain to the Worker. Use separate production clients or realms, register that origin plus `/api/auth/callback/company`, and supply these values through your deployment's server configuration. |
| 337 | |
| 338 | ## Preserve provider identity |
| 339 | |
| 340 | `company` determines both the callback path and stored provider identity: do not rename it or point it at an unrelated issuer once accounts exist. Discovery-based OIDC uses the provider profile's `sub` as the account subject, not a mutable email address, and this configuration requires verified ID tokens with nonce binding enabled. |
| 341 | |
| 342 | Keep implicit linking disabled, verified-email checks, and the same-email restriction if you later add account linking. Follow Better Auth's [account-linking guidance](https://www.better-auth.com/docs/concepts/users-accounts#account-linking) rather than adding `company` to `trustedProviders` or merging rows to bypass ownership checks. |
| 343 | |
| 344 | ## Adapt another provider |
| 345 | |
| 346 | Edit the inline `genericOAuth` configuration in `src/auth.ts`, preserving any other plugins when integrating into a larger application. For plain OAuth, use reviewed `authorizationUrl`, `tokenUrl`, and `userInfoUrl` settings instead of OIDC discovery, and verify the vendor's token authentication, PKCE, identity, and email-trust requirements. |
| 347 | |
| 348 | Plain OAuth defaults to the raw profile's immutable `id`; nonstandard fields require an `accountSubject` resolver and possibly `getUserInfo` or `mapProfileToUser`. Consult the upstream [Generic OAuth documentation](https://www.better-auth.com/docs/plugins/generic-oauth) rather than merely removing ID-token verification from this OIDC example. |
| 349 | |
| 350 | ## Diagnose callback failures |
| 351 | |
| 352 | Better Auth displays callback failures on its built-in `/api/auth/error` page; no custom application error query is needed. Keep ID-token verification, nonce binding, state, origin, and CSRF checks enabled. |
| 353 | |
| 354 | For an unknown provider, check discovery reachability and issuer/JWKS metadata; for callback mismatches, compare the exact `/api/auth/callback/company` registration. For token-exchange failures, compare the secret and authentication method, then start a fresh sign-in instead of replaying codes or logging tokens and full callback URLs. |
| 355 | |
| 356 | ## Resolve email-verification failures |
| 357 | |
| 358 | `requireEmailVerification` checks local user verification and can leave a created or linked account without a session. For `email_not_verified`, review both issuer and local verification state: an existing unverified local user needs a real [email-verification flow](https://www.better-auth.com/docs/concepts/email), not a database edit to mark the user verified. |