@ballatech/effect-oauth-client
v0.3.2
Published
[](https://www.npmjs.com/package/@ballatech/effect-oauth-client) [](https://b
Readme
effect-oauth-client
Effect-first OAuth 2.0 Client Credentials helper for @effect/platform's HttpClient.
- Fetches access tokens using the client credentials grant
- Caches tokens and auto-refreshes near expiry
- Transparently attaches
Authorization: Bearer <token>to outgoing requests - Retries once on 401 responses
Installation
pnpm add @ballatech/effect-oauth-clientThis package expects effect and @effect/platform to be available as peers.
pnpm add effect @effect/platformAPI
import { OAuthClient } from "@ballatech/effect-oauth-client"OAuthClient.make(credentials)→Effect<HttpClient>- Builds an
HttpClientthat automatically obtains and injects access tokens.
- Builds an
Credentials
type Credentials = {
clientId: string
clientSecret: Redacted.Redacted<string>
tokenUrl: string
scope?: string
audience?: string
ttl?: Duration.Duration // default: 3600 seconds
expiryBuffer?: Duration.Duration // default: 300 seconds (refresh ~5m early)
}Notes:
ttlcontrols the cache TTL for the token effect. Actual token expiry is respected via theexpires_invalue and refreshed ~10 seconds early.scopeandaudienceare optional and sent as URL-encoded form parameters.
Errors
OAuthClient can fail with AuthorizationError (a tagged error) with code:
credentials_error: parsing or validation of the token response failedclient_error: HTTP client or response error while obtaining a tokenunauthorized: downstream API responded with 401
Usage
Basic request
import { OAuthClient } from "@ballatech/effect-oauth-client"
import { Effect, Redacted, Schema } from "effect"
import { FetchHttpClient, HttpClientResponse } from "@effect/platform"
const FooSchema = Schema.Struct({ foo: Schema.String })
const program = Effect.gen(function* () {
const client = yield* OAuthClient.make({
clientId: "my-client-id",
clientSecret: Redacted.make("my-secret"),
tokenUrl: "https://auth.example.com/oauth/token",
scope: "read:foo",
})
// The client now automatically includes a Bearer token
const result = yield* client
.get("https://api.example.com/secret-foo")
.pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(FooSchema)),
Effect.scoped
)
return result
})
// Provide an HttpClient implementation (Fetch)
Effect.runPromise(program.pipe(Effect.provide(FetchHttpClient.layer)))With Layer and service composition
import { Context, Effect, Layer, Redacted } from "effect"
import { OAuthClient } from "@ballatech/effect-oauth-client"
import { FetchHttpClient, HttpClientResponse } from "@effect/platform"
const makeService = Effect.gen(function* () {
const client = yield* OAuthClient.make({
clientId: "id123",
clientSecret: Redacted.make("secret"),
tokenUrl: "https://auth.example.com/oauth/token",
})
const getFoo = () =>
client.get("https://api.example.com/secret-foo").pipe(Effect.scoped)
return { getFoo }
})
class MyService extends Context.Tag("MyService")<
MyService,
Effect.Effect.Success<typeof makeService>
>() {}
export const MyServiceLayer = Layer.effect(MyService, makeService).pipe(
Layer.provide(FetchHttpClient.layer)
)Testing (mocking Fetch)
import { beforeEach, describe, expect, it, vi } from "@effect/vitest"
import { Duration, Effect, Layer, ManagedRuntime, Redacted } from "effect"
import { FetchHttpClient } from "@effect/platform"
import { OAuthClient } from "@ballatech/effect-oauth-client"
describe("OAuthClient", () => {
let rt: ManagedRuntime.ManagedRuntime<never, never>
const fetch = vi.fn()
beforeEach(() => {
fetch.mockReset()
const FetchTest = Layer.succeed(FetchHttpClient.Fetch, fetch)
rt = ManagedRuntime.make(FetchHttpClient.layer.pipe(Layer.provide(FetchTest)))
})
it("fetches and reuses token", async () => {
fetch.mockImplementation(async (url: URL) => {
if (url.href.includes("/oauth/token")) {
return new Response(JSON.stringify({ access_token: "t", token_type: "Bearer", expires_in: 3600 }), { status: 200 })
}
return new Response(JSON.stringify({ ok: true }), { status: 200 })
})
const prog = Effect.gen(function* () {
const client = yield* OAuthClient.make({
clientId: "id",
clientSecret: Redacted.make("secret"),
tokenUrl: "https://auth.example.com/oauth/token",
ttl: Duration.seconds(3600),
})
yield* client.get("https://api.example.com/foo").pipe(Effect.scoped)
yield* client.get("https://api.example.com/foo").pipe(Effect.scoped)
})
await rt.runPromise(prog)
})
})Requirements
- Provide an
HttpClientlayer, e.g.FetchHttpClient.layer effectand@effect/platformmust be installed (peer dependencies)
Build
pnpm -w build