@ballatech/effect-oauth-client
v1.0.0
Published
OAuth 2.0 client credentials HttpClient for Effect with token caching and refresh
Maintainers
Readme
effect-oauth-client
Effect-first OAuth 2.0 Client Credentials helper for Effect v4 HTTP HttpClient.
[!WARNING] This version targets Effect v4 (
[email protected]). The APIs use v4 constructs such asContext.Service,ManagedRuntime, andeffect/http. If you are on Effect v3, use an earlier version of this package.See the Effect 4.0 announcement for details.
- 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 downstream 401 responses (invalidates cached token, fetches a fresh one)
- Retries transient token endpoint failures (429, 5xx, network errors) with exponential backoff
Installation
pnpm add @ballatech/effect-oauth-client effectThis package expects
effectv4 as a peer.
API
import { OAuthHttpClient } from "@ballatech/effect-oauth-client"OAuthHttpClient.make(credentials)→Effect<HttpClient>- Builds an
HttpClientthat automatically obtains and injects access tokens.
- Builds an
OAuthHttpClient.makeFromConfig(credentialsConfig)→Effect<HttpClient>- Same as
make, but resolves each credential from aConfigvalue. Useful when credentials come from environment variables or a config provider.
- Same as
OAuthHttpClient.layer(credentials)→Layer<OAuthHttpClient>- Provides an
OAuthHttpClientservice from static credentials.
- Provides an
OAuthHttpClient.layerConfig(credentialsConfig)→Layer<OAuthHttpClient>- Provides an
OAuthHttpClientservice fromConfigvalues.layerFromConfigis a deprecated alias.
- Provides an
OAuthHttpClient.OAuthHttpClient— Built-in service tag for the single-client case. Use withlayer/layerConfig.OAuthHttpClient.Client— Type alias for the authenticatedHttpClientshape. Use this when creating your own service tags for multi-client setups.OAuthHttpClient.AuthorizationError— Tagged error class for all OAuth failures. Its_tagis"OAuthAuthorizationError". Has acodefield:'credentials_error','client_error', or'unauthorized'.OAuthHttpClient.isAuthorizationError(u)— Type guard that narrowsunknowntoAuthorizationError.
Credentials
type Credentials = {
readonly clientId: string
readonly clientSecret: Redacted.Redacted<string>
readonly tokenUrl: string
readonly scope?: string
readonly audience?: string
readonly baseUrl?: string // prepended to all outgoing request URLs
readonly ttl?: Duration.Duration // default: 3600 seconds
readonly expiryBuffer?: Duration.Duration // default: 300 seconds (refresh ~5m early)
}Notes:
baseUrlis prepended to every outgoing request URL, so you can use relative paths likeclient.get("/users")instead of full URLs.ttlcontrols the maximum cache lifetime for the token. The actual token expiry fromexpires_inis also tracked, and the token is proactively refreshedexpiryBufferbefore it expires (default: 5 minutes early).scopeandaudienceare optional and sent as URL-encoded form parameters.
CredentialsConfig
makeFromConfig accepts Config values for fields that typically come from the environment:
type CredentialsConfig = {
readonly clientId: Config.Config<string>
readonly clientSecret: Config.Config<Redacted.Redacted<string>>
readonly tokenUrl: Config.Config<string>
readonly scope?: Config.Config<string>
readonly audience?: Config.Config<string>
readonly baseUrl?: Config.Config<string>
readonly ttl?: Duration.Duration
readonly expiryBuffer?: Duration.Duration
}Errors
OAuthHttpClient can fail with AuthorizationError (a tagged error) with code:
credentials_error: the token endpoint returned a response that doesn't match the expected schema (e.g. 400 with an OAuth error body, or missingaccess_token). Not retried.client_error: transient failure while obtaining a token (network error, 429, 5xx). Retried up to 2 times with exponential backoff before failing.unauthorized: downstream API responded with 401. The cached token is invalidated and the request is retried once with a fresh token. If the retry also returns 401, the error propagates.
Usage
Basic request
import { OAuthHttpClient } from "@ballatech/effect-oauth-client"
import { Effect, Redacted, Schema } from "effect"
import { FetchHttpClient, HttpClientResponse } from "effect/http"
const FooSchema = Schema.Struct({ foo: Schema.String })
const program = Effect.gen(function* () {
const client = yield* OAuthHttpClient.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 { OAuthHttpClient } from "@ballatech/effect-oauth-client"
import { FetchHttpClient, HttpClientResponse } from "effect/http"
const makeService = Effect.gen(function* () {
const client = yield* OAuthHttpClient.make({
clientId: "id123",
clientSecret: Redacted.make("secret"),
tokenUrl: "https://auth.example.com/oauth/token",
baseUrl: "https://api.example.com",
})
const getFoo = () =>
client.get("/secret-foo").pipe(Effect.scoped)
return { getFoo }
})
type MyServiceShape = Effect.Success<typeof makeService>
class MyService extends Context.Service<MyService, MyServiceShape>()("MyService") {}
export const MyServiceLayer = Layer.effect(MyService)(makeService).pipe(
Layer.provide(FetchHttpClient.layer)
)With layer (zero-boilerplate single client)
import { Effect, Layer, Redacted } from "effect"
import { OAuthHttpClient } from "@ballatech/effect-oauth-client"
import { FetchHttpClient, HttpClientResponse } from "effect/http"
const AppLayer = OAuthHttpClient.layer({
clientId: "id123",
clientSecret: Redacted.make("secret"),
tokenUrl: "https://auth.example.com/oauth/token",
baseUrl: "https://api.example.com",
}).pipe(Layer.provide(FetchHttpClient.layer))
const program = OAuthHttpClient.OAuthHttpClient.use((client) =>
client.get("/secret-foo").pipe(Effect.scoped)
)
Effect.runPromise(program.pipe(Effect.provide(AppLayer)))With Config provider (environment variables)
Using layerConfig for the simplest case:
import { Config, Effect, Layer } from "effect"
import { OAuthHttpClient } from "@ballatech/effect-oauth-client"
import { FetchHttpClient } from "effect/http"
const AppLayer = OAuthHttpClient.layerConfig({
clientId: Config.String("OAUTH_CLIENT_ID"),
clientSecret: Config.Redacted("OAUTH_CLIENT_SECRET"),
tokenUrl: Config.String("OAUTH_TOKEN_URL"),
baseUrl: Config.String("API_BASE_URL"),
scope: Config.String("OAUTH_SCOPE"),
}).pipe(Layer.provide(FetchHttpClient.layer))
const program = OAuthHttpClient.OAuthHttpClient.use((client) =>
client.get("/secret-foo").pipe(Effect.scoped)
)
Effect.runPromise(program.pipe(Effect.provide(AppLayer)))Or with makeFromConfig when wrapping in a custom service:
import { Config, Context, Effect, Layer } from "effect"
import { OAuthHttpClient } from "@ballatech/effect-oauth-client"
import { FetchHttpClient } from "effect/http"
const makeService = Effect.gen(function* () {
const client = yield* OAuthHttpClient.makeFromConfig({
clientId: Config.String("OAUTH_CLIENT_ID"),
clientSecret: Config.Redacted("OAUTH_CLIENT_SECRET"),
tokenUrl: Config.String("OAUTH_TOKEN_URL"),
baseUrl: Config.String("API_BASE_URL"),
})
const getFoo = () =>
client.get("/secret-foo").pipe(Effect.scoped)
return { getFoo }
})
type MyServiceShape = Effect.Success<typeof makeService>
class MyService extends Context.Service<MyService, MyServiceShape>()("MyService") {}
export const MyServiceLayer = Layer.effect(MyService)(makeService).pipe(
Layer.provide(FetchHttpClient.layer)
)Multiple OAuth clients
The built-in OAuthHttpClient tag covers the single-client case. When your program
connects to multiple OAuth-protected APIs, create a dedicated tag for each one using
OAuthHttpClient.Client as the shape:
import { Context, Effect, Layer, Redacted } from "effect"
import { OAuthHttpClient } from "@ballatech/effect-oauth-client"
import { FetchHttpClient } from "effect/http"
class AzureClient extends Context.Service<AzureClient, OAuthHttpClient.Client>()("AzureClient") {
static live = Layer.effect(this)(OAuthHttpClient.make({
clientId: "azure-id",
clientSecret: Redacted.make("azure-secret"),
tokenUrl: "https://login.microsoftonline.com/.../oauth2/v2.0/token",
baseUrl: "https://api.azure.example.com",
}))
}
class GoogleClient extends Context.Service<GoogleClient, OAuthHttpClient.Client>()("GoogleClient") {
static live = Layer.effect(this)(OAuthHttpClient.make({
clientId: "google-id",
clientSecret: Redacted.make("google-secret"),
tokenUrl: "https://oauth2.googleapis.com/token",
baseUrl: "https://api.google.example.com",
}))
}
const program = Effect.gen(function* () {
const azure = yield* AzureClient
const google = yield* GoogleClient
const azureData = yield* azure.get("/data").pipe(Effect.scoped)
const googleData = yield* google.get("/data").pipe(Effect.scoped)
})
const AppLayer = Layer.mergeAll(AzureClient.live, GoogleClient.live).pipe(
Layer.provide(FetchHttpClient.layer)
)
Effect.runPromise(program.pipe(Effect.provide(AppLayer)))Testing (mocking Fetch)
import { beforeEach, describe, expect, it, vi } from "@effect/vitest"
import { Duration, Effect, Layer, ManagedRuntime, Redacted } from "effect"
import { FetchHttpClient, type HttpClient } from "effect/http"
import { OAuthHttpClient } from "@ballatech/effect-oauth-client"
describe("OAuthHttpClient", () => {
let rt: ManagedRuntime.ManagedRuntime<HttpClient.HttpClient, 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* OAuthHttpClient.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 effectv4 must be installed (peer dependency) — install withpnpm add effect
Build
pnpm -w build