| 1 | --- |
| 2 | title: "Part 4: Give users their own credentials" |
| 3 | description: Replace the shared credential with Better Auth accounts and individual Git API keys. |
| 4 | --- |
| 5 | |
| 6 | import { Code, Tabs, TabItem } from "@astrojs/starlight/components"; |
| 7 | |
| 8 | One shared credential cannot distinguish one caller from another. Replace it |
| 9 | with Better Auth accounts and API keys, then use the authenticated user's ID |
| 10 | when checking access to a named repository. |
| 11 | |
| 12 | Keep the host and storage from [Part 3](/git/tutorial/part-3). Existing repositories |
| 13 | remain in place. This part creates new repositories under user IDs; it does not |
| 14 | transfer the existing `acme/web` repository to a user. |
| 15 | |
| 16 | ## Install Better Auth |
| 17 | |
| 18 | <Tabs syncKey="pkgManager"> |
| 19 | <TabItem label="bun" icon="bun"> |
| 20 | <Code code={`bun add @alchemy.run/better-auth better-auth @better-auth/api-key`} lang="sh" /> |
| 21 | </TabItem> |
| 22 | <TabItem label="npm" icon="npm"> |
| 23 | <Code code={`npm install @alchemy.run/better-auth better-auth @better-auth/api-key`} lang="sh" /> |
| 24 | </TabItem> |
| 25 | <TabItem label="pnpm" icon="pnpm"> |
| 26 | <Code code={`pnpm add @alchemy.run/better-auth better-auth @better-auth/api-key`} lang="sh" /> |
| 27 | </TabItem> |
| 28 | <TabItem label="yarn" icon="seti:yarn"> |
| 29 | <Code code={`yarn add @alchemy.run/better-auth better-auth @better-auth/api-key`} lang="sh" /> |
| 30 | </TabItem> |
| 31 | </Tabs> |
| 32 | |
| 33 | Better Auth will manage accounts and credentials. Git will continue to store |
| 34 | repositories. |
| 35 | |
| 36 | ## Store accounts in D1 |
| 37 | |
| 38 | Create `src/auth.ts`: |
| 39 | |
| 40 | ```typescript |
| 41 | // src/auth.ts |
| 42 | import { BetterAuth } from "@alchemy.run/better-auth"; |
| 43 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 44 | |
| 45 | export const AuthDb = Cloudflare.D1.Database("AuthDb"); |
| 46 | |
| 47 | export const Auth = BetterAuth({ |
| 48 | basePath: "/api/auth", |
| 49 | emailAndPassword: { enabled: true }, |
| 50 | }); |
| 51 | ``` |
| 52 | |
| 53 | This enables email/password accounts under `/api/auth`. The host will supply |
| 54 | D1 as the database implementation later in this part. |
| 55 | |
| 56 | ## Enable individual API keys |
| 57 | |
| 58 | ```diff lang="typescript" |
| 59 | // src/auth.ts |
| 60 | import { BetterAuth } from "@alchemy.run/better-auth"; |
| 61 | +import { apiKey } from "@better-auth/api-key"; |
| 62 | ``` |
| 63 | |
| 64 | Register the plugin: |
| 65 | |
| 66 | ```diff lang="typescript" |
| 67 | // src/auth.ts |
| 68 | emailAndPassword: { enabled: true }, |
| 69 | + plugins: [apiKey()], |
| 70 | }); |
| 71 | ``` |
| 72 | |
| 73 | A signed-in user can now mint an API key for a Git client. That key replaces the |
| 74 | shared credential in the HTTP Basic password field. |
| 75 | |
| 76 | ## Set a request allowance for Git |
| 77 | |
| 78 | ```diff lang="typescript" |
| 79 | // src/auth.ts |
| 80 | - plugins: [apiKey()], |
| 81 | + plugins: [apiKey({ |
| 82 | + rateLimit: { timeWindow: 60_000, maxRequests: 1_000 }, |
| 83 | + })], |
| 84 | ``` |
| 85 | |
| 86 | A push or clone makes multiple authenticated HTTP requests. Set an explicit |
| 87 | allowance of 1,000 requests per minute for the keys created in this tutorial; |
| 88 | the plugin's default of ten requests per day is too small for these steps. |
| 89 | |
| 90 | ## Represent the caller |
| 91 | |
| 92 | Create `src/session.ts`: |
| 93 | |
| 94 | ```typescript |
| 95 | // src/session.ts |
| 96 | import * as Context from "effect/Context"; |
| 97 | |
| 98 | export class Session extends Context.Service< |
| 99 | Session, |
| 100 | { readonly user: { readonly id: string } | null } |
| 101 | >()("app/Session") {} |
| 102 | ``` |
| 103 | |
| 104 | This service carries the caller through one request. A user has an ID; |
| 105 | `null` represents an anonymous caller reading a public repository. |
| 106 | |
| 107 | ## Resolve a session cookie |
| 108 | |
| 109 | Create `src/credentials.ts`: |
| 110 | |
| 111 | ```typescript |
| 112 | // src/credentials.ts |
| 113 | import * as Effect from "effect/Effect"; |
| 114 | import { Auth } from "./auth.ts"; |
| 115 | |
| 116 | export const ResolveUser = Effect.gen(function* () { |
| 117 | const auth = yield* Auth; |
| 118 | return Effect.gen(function* () { |
| 119 | const session = yield* auth.getSession().pipe( |
| 120 | Effect.catchTag("BetterAuthApiError", () => Effect.succeed(null)), |
| 121 | ); |
| 122 | return session ? { id: session.user.id.toLowerCase() } : null; |
| 123 | }); |
| 124 | }); |
| 125 | ``` |
| 126 | |
| 127 | As with `PublicRead`, the outer effect acquires a dependency and returns a check |
| 128 | that runs per request. Better Auth reads the request's session cookie. Lowercase |
| 129 | the ID because Git repository owners are normalized to lowercase. |
| 130 | |
| 131 | ## Accept an API key from Git |
| 132 | |
| 133 | Add the HTTP Basic decoder imports: |
| 134 | |
| 135 | ```diff lang="typescript" |
| 136 | // src/credentials.ts |
| 137 | import * as Effect from "effect/Effect"; |
| 138 | +import * as Redacted from "effect/Redacted"; |
| 139 | +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 140 | +import * as HttpApiSecurity from "effect/http-api/HttpApiSecurity"; |
| 141 | ``` |
| 142 | |
| 143 | Check for an API key before looking for a session cookie: |
| 144 | |
| 145 | ```diff lang="typescript" |
| 146 | // src/credentials.ts |
| 147 | const auth = yield* Auth; |
| 148 | return Effect.gen(function* () { |
| 149 | + const { password } = yield* HttpApiBuilder.securityDecode(HttpApiSecurity.basic); |
| 150 | + const key = Redacted.value(password); |
| 151 | + if (key !== "") { |
| 152 | + const verified = yield* auth.api.verifyApiKey({ body: { key } }).pipe( |
| 153 | + Effect.catchTag("BetterAuthApiError", () => |
| 154 | + Effect.succeed({ valid: false as const, key: null }), |
| 155 | + ), |
| 156 | + ); |
| 157 | + return verified.valid && verified.key |
| 158 | + ? { id: verified.key.referenceId.toLowerCase() } |
| 159 | + : null; |
| 160 | + } |
| 161 | const session = yield* auth.getSession().pipe( |
| 162 | ``` |
| 163 | |
| 164 | The key identifies its owner. An invalid key produces an anonymous caller, so |
| 165 | it cannot grant access to a private repository or a write operation. |
| 166 | |
| 167 | ## Authorize the identified user |
| 168 | |
| 169 | Replace `src/middleware.ts` with: |
| 170 | |
| 171 | ```typescript |
| 172 | // src/middleware.ts |
| 173 | import { RuntimeContext } from "alchemy"; |
| 174 | import * as Effect from "effect/Effect"; |
| 175 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 176 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 177 | import { ResolveUser } from "./credentials.ts"; |
| 178 | import { PublicRead } from "./public-read.ts"; |
| 179 | import { Session } from "./session.ts"; |
| 180 | |
| 181 | export const Authentication = HttpRouter.middleware<{ provides: Session }>()( |
| 182 | Effect.gen(function* () { |
| 183 | const resolveUser = yield* ResolveUser; |
| 184 | const publicRead = yield* PublicRead; |
| 185 | return (httpEffect) => |
| 186 | Effect.gen(function* () { |
| 187 | const user = yield* resolveUser; |
| 188 | const { owner } = yield* HttpRouter.params; |
| 189 | const own = owner === undefined || owner.toLowerCase() === user?.id; |
| 190 | if ((user !== null && own) || (yield* publicRead)) { |
| 191 | return yield* Effect.provideService(httpEffect, Session, { user }); |
| 192 | } |
| 193 | return HttpServerResponse.jsonUnsafe( |
| 194 | { _tag: "Unauthorized" }, |
| 195 | { status: 401, headers: { "www-authenticate": 'Basic realm="git"' } }, |
| 196 | ); |
| 197 | }).pipe(Effect.provide(RuntimeContext.phantom)); |
| 198 | }), |
| 199 | ); |
| 200 | ``` |
| 201 | |
| 202 | For routes naming an owner, the user's ID must match that owner unless the |
| 203 | request is a public read. The middleware also provides `Session` for application |
| 204 | handlers to use in Part 5. Routes without an owner parameter require a signed-in |
| 205 | user; they do not acquire automatic tenant filtering. This tutorial creates |
| 206 | repositories under the caller's ID explicitly. |
| 207 | |
| 208 | ## Serve Better Auth's routes |
| 209 | |
| 210 | Add the auth imports to `src/host.ts`: |
| 211 | |
| 212 | ```diff lang="typescript" |
| 213 | // src/host.ts |
| 214 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 215 | +import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 216 | +import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; |
| 217 | +import { Auth, AuthDb } from "./auth.ts"; |
| 218 | ``` |
| 219 | |
| 220 | Initialize Better Auth beside the Git router: |
| 221 | |
| 222 | ```diff lang="typescript" |
| 223 | // src/host.ts |
| 224 | Effect.gen(function* () { |
| 225 | + const auth = yield* Auth; |
| 226 | const fetch = yield* HttpRouter.toHttpEffect( |
| 227 | ``` |
| 228 | |
| 229 | Send `/api/auth` requests to Better Auth. They must be reachable before a user |
| 230 | has signed in: |
| 231 | |
| 232 | ```diff lang="typescript" |
| 233 | // src/host.ts |
| 234 | - return { fetch }; |
| 235 | + return { |
| 236 | + fetch: Effect.gen(function* () { |
| 237 | + const request = yield* HttpServerRequest; |
| 238 | + const path = request.url.split("?")[0]; |
| 239 | + if (path === "/api/auth" || path?.startsWith("/api/auth/")) { |
| 240 | + return yield* auth.fetch; |
| 241 | + } |
| 242 | + return yield* fetch; |
| 243 | + }), |
| 244 | + }; |
| 245 | ``` |
| 246 | |
| 247 | Supply the D1 database on the Worker's initialization effect: |
| 248 | |
| 249 | ```diff lang="typescript" |
| 250 | // src/host.ts |
| 251 | - }), |
| 252 | + }).pipe(Effect.provide(CloudflareD1(AuthDb))), |
| 253 | ) {} |
| 254 | ``` |
| 255 | |
| 256 | The adapter binds D1 to the Worker and runs Better Auth's schema migrations at |
| 257 | deploy time. Git's bucket and Durable Objects are unchanged. |
| 258 | |
| 259 | ## Remove the shared credential |
| 260 | |
| 261 | Remove its imports from `alchemy.run.ts`: |
| 262 | |
| 263 | ```diff lang="typescript" |
| 264 | // alchemy.run.ts |
| 265 | -import * as Output from "alchemy/Output"; |
| 266 | -import * as Redacted from "effect/Redacted"; |
| 267 | -import { GitSecret } from "./src/secret.ts"; |
| 268 | ``` |
| 269 | |
| 270 | Return only the URL again: |
| 271 | |
| 272 | ```diff lang="typescript" |
| 273 | // alchemy.run.ts |
| 274 | const host = yield* GitHost; |
| 275 | - const secret = yield* GitSecret; |
| 276 | - return { |
| 277 | - url: host.url.as<string>(), |
| 278 | - secret: Output.map(secret.text, Redacted.value), |
| 279 | - }; |
| 280 | + return { url: host.url.as<string>() }; |
| 281 | ``` |
| 282 | |
| 283 | `src/secret.ts` is now unused and can be deleted. The old credential will no |
| 284 | longer authorize requests after the next deploy. |
| 285 | |
| 286 | ## Deploy accounts and credentials |
| 287 | |
| 288 | <Tabs syncKey="pkgManager"> |
| 289 | <TabItem label="bun" icon="bun"> |
| 290 | <Code code={`bun alchemy deploy`} lang="sh" /> |
| 291 | </TabItem> |
| 292 | <TabItem label="npm" icon="npm"> |
| 293 | <Code code={`npx alchemy deploy`} lang="sh" /> |
| 294 | </TabItem> |
| 295 | <TabItem label="pnpm" icon="pnpm"> |
| 296 | <Code code={`pnpm alchemy deploy`} lang="sh" /> |
| 297 | </TabItem> |
| 298 | <TabItem label="yarn" icon="seti:yarn"> |
| 299 | <Code code={`yarn alchemy deploy`} lang="sh" /> |
| 300 | </TabItem> |
| 301 | </Tabs> |
| 302 | |
| 303 | Install `jq` for the following shell checks if you do not already have it. |
| 304 | Continue using the same `$HOST`. |
| 305 | |
| 306 | ## Sign up |
| 307 | |
| 308 | ```sh |
| 309 | curl --fail-with-body -c dana.cookies \ |
| 310 | -X POST "$HOST/api/auth/sign-up/email" \ |
| 311 | -H "Origin: $HOST" -H "Content-Type: application/json" \ |
| 312 | -d '{"name":"Dana","email":"dana@example.com","password":"tutorial-password-dana"}' \ |
| 313 | > dana.json |
| 314 | export OWNER=$(jq -r '.user.id | ascii_downcase' dana.json) |
| 315 | ``` |
| 316 | |
| 317 | The response gives you Dana's ID and sets a session cookie. `curl -c` saves that |
| 318 | cookie; `curl -b` will send it on later requests. `Origin` is included for Better |
| 319 | Auth's checks on requests using cookies. |
| 320 | |
| 321 | If you repeat these steps after creating the account, use `/api/auth/sign-in/email` |
| 322 | with the same email and password to obtain a new cookie. |
| 323 | |
| 324 | ## Create a private repository for Dana |
| 325 | |
| 326 | ```sh |
| 327 | curl --fail-with-body -b dana.cookies -X POST "$HOST/api/v1/repos" \ |
| 328 | -H "Origin: $HOST" -H "Content-Type: application/json" \ |
| 329 | -d "{\"owner\":\"$OWNER\",\"name\":\"web\",\"public\":false}" |
| 330 | ``` |
| 331 | |
| 332 | This is a new repository named `$OWNER/web`. The earlier `acme/web` remains |
| 333 | public and readable, but neither Dana nor another new account owns its namespace. |
| 334 | |
| 335 | ## Mint a Git credential |
| 336 | |
| 337 | ```sh |
| 338 | export KEY=$(curl --fail-with-body -b dana.cookies \ |
| 339 | -X POST "$HOST/api/auth/api-key/create" \ |
| 340 | -H "Origin: $HOST" -H "Content-Type: application/json" \ |
| 341 | -d '{"name":"laptop"}' | jq -r .key) |
| 342 | ``` |
| 343 | |
| 344 | The returned key is the Git password for Dana's account. Save it; Better Auth |
| 345 | shows the complete key when it is created. |
| 346 | |
| 347 | ## Push as Dana |
| 348 | |
| 349 | ```sh |
| 350 | git -C work remote set-url origin "$HOST/$OWNER/web.git" |
| 351 | git -c credential.helper= -C work push -u origin main |
| 352 | ``` |
| 353 | |
| 354 | Enter `x` as the username and `$KEY` as the password. Clone the private repository |
| 355 | with the same key: |
| 356 | |
| 357 | ```sh |
| 358 | git -c credential.helper= clone "$HOST/$OWNER/web.git" dana-copy |
| 359 | git -C dana-copy fsck --strict |
| 360 | ``` |
| 361 | |
| 362 | ## Check isolation between users |
| 363 | |
| 364 | Create a second account: |
| 365 | |
| 366 | ```sh |
| 367 | curl --fail-with-body -c alex.cookies \ |
| 368 | -X POST "$HOST/api/auth/sign-up/email" \ |
| 369 | -H "Origin: $HOST" -H "Content-Type: application/json" \ |
| 370 | -d '{"name":"Alex","email":"alex@example.com","password":"tutorial-password-alex"}' \ |
| 371 | > alex.json |
| 372 | export ALEX=$(jq -r '.user.id | ascii_downcase' alex.json) |
| 373 | ``` |
| 374 | |
| 375 | Alex can create a separate private repository: |
| 376 | |
| 377 | ```sh |
| 378 | curl --fail-with-body -b alex.cookies -X POST "$HOST/api/v1/repos" \ |
| 379 | -H "Origin: $HOST" -H "Content-Type: application/json" \ |
| 380 | -d "{\"owner\":\"$ALEX\",\"name\":\"web\",\"public\":false}" |
| 381 | curl -i -b alex.cookies "$HOST/api/v1/repos/$ALEX/web" |
| 382 | ``` |
| 383 | |
| 384 | Expect `200` for Alex's own repository. Reading Dana's private repository with |
| 385 | Alex's cookie must fail: |
| 386 | |
| 387 | ```sh |
| 388 | curl -i -b alex.cookies "$HOST/api/v1/repos/$OWNER/web" |
| 389 | ``` |
| 390 | |
| 391 | Expect `401`. With `dana.cookies` the same request returns `200`. |
| 392 | |
| 393 | [Part 5: Add your application's API](/git/tutorial/part-5) uses `Session` in a |
| 394 | route you implement yourself. |