npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ballatech/effect-oauth-client

v1.0.0

Published

OAuth 2.0 client credentials HttpClient for Effect with token caching and refresh

Readme

effect-oauth-client

npm version Checked with Biome

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 as Context.Service, ManagedRuntime, and effect/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 effect

This package expects effect v4 as a peer.

API

import { OAuthHttpClient } from "@ballatech/effect-oauth-client"
  • OAuthHttpClient.make(credentials) → Effect<HttpClient>
    • Builds an HttpClient that automatically obtains and injects access tokens.
  • OAuthHttpClient.makeFromConfig(credentialsConfig) → Effect<HttpClient>
    • Same as make, but resolves each credential from a Config value. Useful when credentials come from environment variables or a config provider.
  • OAuthHttpClient.layer(credentials) → Layer<OAuthHttpClient>
    • Provides an OAuthHttpClient service from static credentials.
  • OAuthHttpClient.layerConfig(credentialsConfig) → Layer<OAuthHttpClient>
    • Provides an OAuthHttpClient service from Config values. layerFromConfig is a deprecated alias.
  • OAuthHttpClient.OAuthHttpClient — Built-in service tag for the single-client case. Use with layer / layerConfig.
  • OAuthHttpClient.Client — Type alias for the authenticated HttpClient shape. Use this when creating your own service tags for multi-client setups.
  • OAuthHttpClient.AuthorizationError — Tagged error class for all OAuth failures. Its _tag is "OAuthAuthorizationError". Has a code field: 'credentials_error', 'client_error', or 'unauthorized'.
  • OAuthHttpClient.isAuthorizationError(u) — Type guard that narrows unknown to AuthorizationError.

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:

  • baseUrl is prepended to every outgoing request URL, so you can use relative paths like client.get("/users") instead of full URLs.
  • ttl controls the maximum cache lifetime for the token. The actual token expiry from expires_in is also tracked, and the token is proactively refreshed expiryBuffer before it expires (default: 5 minutes early).
  • scope and audience are 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 missing access_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 HttpClient layer, e.g. FetchHttpClient.layer
  • effect v4 must be installed (peer dependency) — install with pnpm add effect

Build

pnpm -w build