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

gw-auth

v0.10.1

Published

Framework-neutral TypeScript authentication core with explicit Next.js adapters.

Readme

gw-auth

gw-auth/core centralizes authentication and its security invariants without knowing which application framework consumes it. It supports password, social, guest, password-recovery, rotating-session, and resumable account-deletion flows.

gw-auth/nextjs converts those plain operations to App Router Route Handlers and Server Actions. gw-auth/nextjs/client provides the matching client-side request helpers without choosing application routes.

Installation

npm install gw-auth

Node.js 20 or newer is required.

Package exports

import {
  type AccountDeletionRepository,
  authErrorCategory,
  authStateFromAccessPayload,
  createAuth,
  type AuthState,
  type PasswordRepository,
  type SocialRepository,
} from "gw-auth/core";

import {
  createAuthRoute,
  getAuth,
  getAuthWithRefresh,
  routeHandler,
  serverAction,
  withAuth,
} from "gw-auth/nextjs";
import {
  AuthProvider,
  authRequest,
  startOAuth,
  useAuth,
} from "gw-auth/nextjs/client";

import {
  assertAccountDeletionRepositoryConformance,
  assertOAuthTransactionRepositoryConformance,
  assertPasswordResetRepositoryConformance,
  assertSessionRepositoryConformance,
  assertSocialRepositoryConformance,
} from "gw-auth/testing";
  • gw-auth/core contains the framework-neutral facade, operation types, and storage ports.
  • gw-auth/nextjs contains server-only App Router adapters.
  • gw-auth/nextjs/client is the explicit client-module boundary.
  • gw-auth/testing contains Node.js repository-contract assertions for consumer test suites.

Common setup

Only session infrastructure, token policy, and shared browser-cookie policy are configured initially.

import { createAuth } from "gw-auth/core";

export const auth = createAuth({
  serviceName: "my-service",
  sessions: sessionRepository,

  tokens: {
    access: {
      secret: process.env.ACCESS_TOKEN_SECRET!,
      expiresIn: "15m",
    },
    refresh: {
      secret: process.env.REFRESH_TOKEN_SECRET!,
      expiresIn: "30d",
    },
  },
});

serviceName is the stable identifier for this authentication boundary. The package uses it as both JWT issuer and audience and prefixes every default cookie name with it. It may contain letters, numbers, dots, underscores, and hyphens.

Create this facade once for a service. Browser and mobile clients of the same service must share the same auth, repositories, token policy, issuer, and audience. Do not create webAuth and mobileAuth, and do not put a platform name in serviceName merely to separate delivery environments.

Each token secret must contain at least 32 UTF-8 bytes, and the access and refresh secrets must be different. The package validates the issuer, audience, expiration, token purpose, user, session, and refresh rotation fields internally.

Application claims cannot override JWT-managed aud, exp, iat, iss, jti, nbf, sub, tokenUse, userId, or sessionId fields. These names are removed before token issuance and are omitted from AuthState at the type level.

Cookie configuration is optional. Defaults are Secure, HttpOnly, SameSite=Lax, and Path=/. OAuth state defaults to SameSite=None so Apple form_post callbacks remain bound to the initiating browser. HttpOnly cannot be disabled. For example, serviceName: "my-service" produces my-service_access_token and my-service_refresh_token.

Next.js applications that render authenticated state in Server Components must also configure the withAuth Proxy when users should remain signed in after the access token expires. Server Component reads cannot write rotated cookies, so a refresh boundary is required even while the refresh cookie remains valid.

Existing services may override a cookie name during migration:

browser: {
  cookies: {
    accessToken: { name: "legacy_access_token" },
  },
}

Composition order

Authentication features are configured before selecting their delivery environment:

auth.<feature>(feature dependencies).<browser|mobile>(environment options)

Configure a feature once, then project that same object where needed:

const password = auth.password({ repository });

const browserPassword = password.browser();
const mobilePassword = password.mobile();

const social = auth.social({ repository });
const google = social.google({ clientId, clientSecret });

const browserGoogle = google.browser({ redirectUri });
const mobileGoogle = google.mobile();

Apple first selects the provider API because Android uses Apple's Browser API, while iOS uses its Native API:

const apple = social.apple({ authKey, teamId, keyId });

const webApple = apple.browser({
  serviceId: webServiceId,
  redirectUri: webRedirectUri,
}).web();
const androidApple = apple.browser({
  serviceId: androidServiceId,
  redirectUri: androidRedirectUri,
}).android({ packageId: "com.example.app" });

const iosApple = apple.native({ appId }).ios();

Feature repositories are required only when their feature is enabled.

Password authentication

const password = auth.password({
  repository: passwordRepository,
});

const browserPassword = password.browser();
const mobilePassword = password.mobile();

Both projections accept the same typed input:

await browserPassword.login({
  id: "[email protected]",
  password: "secret",
});

await mobilePassword.signup({
  id: "[email protected]",
  password: "secret",
  passwordConfirm: "secret",
  registration: {
    displayName: "Member",
    gender: "other",
  },
});

Browser success returns only AuthState plus cookie mutations. Mobile success returns the access and refresh tokens explicitly. The application must validate its registration value before calling signup.

Password login, signup, and recovery reject inputs that bcrypt would truncate after 72 UTF-8 bytes. Applications still own product rules such as minimum length and character requirements.

PasswordRepository.createPasswordAccount must atomically create the random internal user and password credential, and must enforce uniqueness for the normalized credential identifier.

Social authentication

Configure social persistence once and reuse it across providers:

const social = auth.social({
  repository: socialRepository,
});

For browser OAuth, the same object may implement both SocialRepository and OAuthTransactionRepository. When transaction storage is separate, pass it once:

const social = auth.social({
  repository: socialRepository,
  transactions: oauthTransactionRepository,
});

Native-token-only social authentication does not require OAuth transaction storage. Browser OAuth and Apple on Android do require it.

Browser Google OAuth

const google = social.google({
  clientId: process.env.GOOGLE_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}).browser({
  redirectUri: "https://app.example.com/auth/google/callback",
});

start and complete are framework-neutral operations:

const started = await google.start({
  redirectPath: "/settings",
});

if (started.result.isErr) {
  // Map started.result.error at the application boundary.
  return;
}

await applyCookieMutations(started.cookies);
await redirect(started.result.value.authorizationUrl);

The callback adapter parses its request and cookies before calling the package:

const completed = await google.complete({
  code,
  state,
  cookies: parsedCookies,
});

await applyCookieMutations(completed.cookies);

completed.result.value is one of:

type OAuthCompleteOutput =
  | {
      status: "authenticated";
      auth: AuthState;
      redirectPath: string;
    }
  | {
      status: "signup_required";
      profile: SocialSignupProfile;
      redirectPath: string;
    };

The package never chooses an application signup route or constructs a redirect from an incoming host. The adapter owns navigation and may use only the already validated relative redirectPath returned by the package.

Other browser providers

social.kakao({ clientId, clientSecret }).browser({ redirectUri });
social.naver({ clientId, clientSecret }).browser({ redirectUri });

Mobile providers

const google = social.google({ clientId }).mobile({
  clientIds: [iosClientId, androidClientId],
});

await google.login({ idToken });
await social.kakao().mobile().login({ accessToken });
await social.naver().mobile().login({ accessToken });

Provider credentials are verified before any local account action. Google and Apple verify signed identity tokens. Kakao and Naver resolve access tokens through their official profile endpoints. Bundled provider HTTP calls and remote-key downloads use a 10-second timeout. Invalid credentials retain their provider-specific error code; transport failures, throttling, and upstream 5xx responses use PROVIDER_UNAVAILABLE, while malformed successful responses use INVALID_PROVIDER_RESPONSE.

Apple Browser and Native APIs

Apple signing credentials are shared, but the client identifier and callback contract depend on the Apple API being used:

const apple = social.apple({
  authKey: process.env.APPLE_AUTH_KEY!,
  teamId: process.env.APPLE_TEAM_ID!,
  keyId: process.env.APPLE_KEY_ID!,
});

const web = apple.browser({
  serviceId: process.env.APPLE_SERVICE_ID!,
  redirectUri: "https://app.example.com/api/auth/apple/callback",
}).web();

const ios = apple.native({
  appId: process.env.APPLE_APP_ID!,
}).ios();

await ios.login({ authorizationCode });

Apple returns a provider refresh token only during the initial authorization. Persist it encrypted together with the providerClientId returned in the verified SocialIdentity, then use both values during account deletion:

await apple.revoke({
  providerRefreshToken: decryptedStoredToken,
  providerClientId: storedIssuingClientId,
});

Revocation belongs to the base apple feature because the stored client ID selects the correct Services ID or App ID; it is not available on .web(), .android({ packageId }), or .ios() projections.

  • Website login uses Apple's Browser API, a Services ID, and an exact HTTPS return URI.
  • Native iOS login uses Apple's Native API and the app's App ID. Its token exchange does not send redirect_uri.
  • Android login through Flutter's sign_in_with_apple package also uses the Browser API. It therefore needs a Services ID and HTTPS return URI, but returns explicit application session tokens rather than browser cookies.

The Android flow starts on the server so state and nonce remain bound to one single-use transaction:

const android = apple.browser({
  serviceId: process.env.APPLE_SERVICE_ID!,
  redirectUri: "https://app.example.com/api/auth/mobile/apple/callback",
}).android({ packageId: "com.example.app" });

const attempt = await android.start();

if (attempt.isOk) {
  // Give serviceId, redirectUri, state, and nonce to getAppleIDCredential.
}

For Flutter Android, the HTTPS callback must relay Apple's original callback fields to the plugin's exact Intent URI. Core validates the package identifier, allowed fields, required outcome, and field lengths before building that URI. The prebuilt Next.js AuthRoute below parses the form and redirects automatically. A custom HTTP adapter parses text form fields, calls handoff, and uses its validated destination:

const handoff = android.handoff(parsedTextFormFields);

if (handoff.isOk) {
  return Response.redirect(handoff.value.redirectUrl, 302);
}

The resulting redirect has this fixed form without consuming or replacing code, state, or id_token:

intent://callback?<apple-callback-fields>#Intent;package=<android-package-id>;scheme=signinwithapple;end

The Android app must also register the plugin callback activity under <application> as documented by sign_in_with_apple:

<activity
    android:name="com.aboutyou.dart_packages.sign_in_with_apple.SignInWithAppleCallback"
    android:exported="true">
  <intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="signinwithapple" />
    <data android:path="callback" />
  </intent-filter>
</activity>

The Flutter client passes the server-issued values to the plugin, checks the returned state, and sends the authorization code and original state back to the completion endpoint:

final credential = await SignInWithApple.getAppleIDCredential(
  scopes: [AppleIDAuthorizationScopes.email],
  webAuthenticationOptions: WebAuthenticationOptions(
    clientId: attempt.serviceId,
    redirectUri: Uri.parse(attempt.redirectUri),
  ),
  state: attempt.state,
  nonce: attempt.nonce,
);

if (credential.state != attempt.state) {
  throw StateError('Apple OAuth state mismatch');
}

await completeAppleLogin(
  authorizationCode: credential.authorizationCode,
  state: attempt.state,
);

Staged social signup

An unknown provider identity does not create a partial account. It creates a short-lived, hashed, single-use signup attempt so the application can collect fields such as gender, nickname, and terms acceptance.

Browser adapters read the configured HttpOnly signup cookie and supply parsed cookie values:

const signup = social.signup.browser();

const profile = await signup.profile({ cookies: parsedCookies });

const completed = await signup.complete({
  cookies: parsedCookies,
  registration: validatedRegistration,
});

Mobile clients receive and return an explicit signupToken:

await social.signup.mobile().complete({
  signupToken,
  registration: validatedRegistration,
});

SocialRepository.completeSocialSignup must atomically consume the attempt, create the random internal user, and link the unique (provider, providerUserId) identity. Never link accounts based only on equal email addresses.

Sessions

Session behavior is shared by every authentication feature.

const browserSession = auth.session.browser();

await browserSession.verify({ cookies: parsedCookies });
await browserSession.refresh({ cookies: parsedCookies });
await browserSession.logout({ cookies: parsedCookies });
const mobileSession = auth.session.mobile();

await mobileSession.verify({ accessToken });
await mobileSession.refresh({ refreshToken });
await mobileSession.logout({ refreshToken });

Refresh tokens rotate through one atomic repository operation. Requests using the immediately previous token within 10 seconds are treated as normal overlap from concurrent RSC or API work and receive the exact same current refresh token. Reuse outside that window revokes the session family. This decision must be persisted and atomic; a process-local lock does not protect multiple server instances.

RefreshSession stores the current token hash, its jti and exact JWT timestamps, the exact rotation time, plus only the previous token hash. The bearer token itself is never stored. rotateRefreshSession must atomically return rotated, concurrent, invalid, or reused; the reused branch must delete the row before it returns. Run assertSessionRepositoryConformance against the implementation.

Browser logout returns matching cookie deletions even when no refresh cookie is present. A terminal browser refresh failure also returns access and refresh cookie deletions; adapters must apply those effects on the error branch instead of interpreting error codes.

Direct session verification intentionally returns the full access-token payload for server authorization. Use the core-owned conversion when exposing browser-safe state through another adapter:

const verified = await browserSession.verify({ cookies: parsedCookies });

if (verified.isOk) {
  const authState = authStateFromAccessPayload(verified.value);
}

Account deletion

Configure account deletion once, reusing the already configured Apple feature when Apple identities may be linked:

const account = auth.account({
  repository: accountDeletionRepository,
  providers: { apple },
});

await account.browser().delete({ cookies: parsedCookies });
await account.mobile().delete({ accessToken });

Neither operation accepts a userId; core derives it only from a verified access token. A successful browser deletion returns access- and refresh-cookie deletions. Mobile deletion returns no replacement tokens.

AccountDeletionRepository.beginAccountDeletion must atomically mark the user as deletion-pending and revoke all local refresh sessions. Password, social, guest, and session repositories must refuse pending users and prevent a new session from racing with that transition. It returns only unfinished provider revocations, with Apple refresh tokens decrypted for this in-process call. The tokens must remain encrypted at rest.

Core calls the configured apple.revoke with each stored issuing client ID, records each successful revocation through completeAccountProviderRevocation, and calls completeAccountDeletion only when none remain. Provider failure returns ACCOUNT_PROVIDER_REVOCATION_FAILED and preserves the pending deletion. A trusted server maintenance job can resume it without an end-user token:

await account.retryPending(pendingUserId);

completeAccountDeletion owns the application's hard deletion, soft deletion, or anonymization policy, but it must remove every authentication credential and reject completion while provider work remains. Access tokens are stateless and remain cryptographically valid until expiry; authorization requiring immediate revocation must also consult current application user state and reject pending or deleted users.

Guest authentication

const browserGuest = auth.guest({ repository: guestRepository }).browser();
const operation = await browserGuest.authenticate({ cookies: parsedCookies });
const mobileGuest = auth.guest({ repository: guestRepository }).mobile();
const result = await mobileGuest.authenticate({ guestCredential });

Guest credentials are server-generated, stored only as hashes, and rotated on use. Never use a client device identifier as a guest credential.

Password recovery

const recovery = auth.passwordRecovery({
  repository: passwordResetRepository,
  mailer,
  siteOrigin: "https://app.example.com",
  resetPath: "/reset-password",
  onRequestError: (error) => logger.error({ error }, "password reset request failed"),
});

await recovery.request({ credentialId });
await recovery.reset({ token, password, passwordConfirm });

Password-reset discovery returns the same public success for known and unknown accounts, including known-account attempt-storage and mail-delivery failures. Use onRequestError for internal reporting; a failure in that observer is also concealed. Account-lookup infrastructure failure remains an explicit system error because it affects every request. Completion must atomically consume the attempt, update the password, and revoke all sessions for that user only.

Browser operation contract

Browser methods return data rather than framework responses:

type BrowserOperation<T> = {
  result: Result<T, AuthError>;
  cookies: readonly BrowserCookieMutation[];
};

Adapters must apply cookies on both success and failure. OAuth completion, for example, deletes its state cookie even when provider verification fails. An adapter then decides how to express the result as a route response, redirect, or server action. HTTP adapters must add Cache-Control: no-store to every authentication or credential-bearing response.

Next.js App Router

Use the fixed AuthRoute for the shortest setup. Use the lower-level adapters when the application needs different paths, body schemas, validation, or redirect behavior. Both choices keep Next.js outside the core package.

Prebuilt AuthRoute

createAuthRoute turns the same unprojected feature objects into browser and mobile routes. Do not call .browser() or .mobile() before passing a feature to it. For Google, Kakao, and Naver, explicitly select each enabled delivery so a mobile-only provider never requires browser credentials.

// src/auth.ts
import { createAuth } from "gw-auth/core";
import { createAuthRoute } from "gw-auth/nextjs";

export const auth = createAuth({
  serviceName: "my-service",
  sessions: sessionRepository,
  tokens: tokenOptions,
});

const password = auth.password({ repository: passwordRepository });
const social = auth.social({
  repository: socialRepository,
  transactions: oauthTransactionRepository,
});
const apple = social.apple({
  authKey: process.env.APPLE_AUTH_KEY!,
  teamId: process.env.APPLE_TEAM_ID!,
  keyId: process.env.APPLE_KEY_ID!,
});
const account = auth.account({
  repository: accountDeletionRepository,
  providers: { apple },
});

export const authRoute = createAuthRoute({
  siteOrigin: "https://app.example.com",
  session: auth.session,
  account,
  password,
  social: {
    signup: social.signup,
    google: {
      feature: social.google({
        clientId: process.env.GOOGLE_CLIENT_ID!,
        clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      }),
      browser: true,
      mobile: {
        clientIds: [
          process.env.GOOGLE_IOS_CLIENT_ID!,
          process.env.GOOGLE_ANDROID_CLIENT_ID!,
        ],
      },
    },
    apple: {
      feature: apple,
      web: { serviceId: process.env.APPLE_WEB_SERVICE_ID! },
      android: {
        serviceId: process.env.APPLE_ANDROID_SERVICE_ID!,
        packageId: "com.example.app",
      },
      ios: { appId: process.env.APPLE_APP_ID! },
    },
  },
});

The catch-all Route Handler only re-exports the two generated methods:

// app/api/auth/[...auth]/route.ts
import { authRoute } from "@/auth";

export const { GET, POST } = authRoute;

The preset owns these contracts:

| Client | Method and path | Body or query | | --- | --- | --- | | Browser | GET /api/auth/session | none | | Browser | POST /api/auth/login | { id, password } | | Browser | POST /api/auth/signup | { id, password, passwordConfirm, registration } | | Browser | POST /api/auth/refresh | none; refresh token comes from cookies | | Browser | POST /api/auth/logout | none; refresh token comes from cookies | | Browser | POST /api/auth/account/delete | none; access token comes from cookies | | Browser | POST /api/auth/guest | none; guest credential comes from cookies | | Browser | GET /api/auth/:provider | optional redirectPath query | | Browser | GET or POST /api/auth/:provider/callback | provider callback fields | | Browser | GET /api/auth/social-signup | staged profile from cookies | | Browser | POST /api/auth/social-signup | { registration } | | Shared | POST /api/auth/password-reset/request | { credentialId } | | Shared | POST /api/auth/password-reset/complete | { token, password, passwordConfirm } | | Mobile | POST /api/auth/mobile/password/login | { id, password } | | Mobile | POST /api/auth/mobile/password/signup | { id, password, passwordConfirm, registration } | | Mobile | POST /api/auth/mobile/refresh | { refreshToken } | | Mobile | POST /api/auth/mobile/logout | { refreshToken } | | Mobile | POST /api/auth/mobile/account/delete | Authorization: Bearer <accessToken> | | Mobile | POST /api/auth/mobile/guest | { guestCredential? } | | Mobile | POST /api/auth/mobile/:provider | Google { idToken }; Kakao or Naver { accessToken } | | iOS | POST /api/auth/mobile/apple/native | { authorizationCode } | | Android | POST /api/auth/mobile/apple/browser/start | none; returns Services ID, return URI, state, and nonce | | Android callback | POST /api/auth/mobile/apple/callback | Apple form_post; redirects to the Flutter plugin Intent | | Android | POST /api/auth/mobile/apple/browser | { authorizationCode, state } | | Mobile | POST /api/auth/mobile/social-signup | { signupToken, registration } |

Only enabled features and providers register their routes. All JSON responses use { ok: true, value? } or { ok: false, error } and send Cache-Control: no-store. GET /api/auth/session returns only the normalized AuthState; JWT transport metadata such as iat, exp, iss, aud, and tokenUse is never part of that client contract. Browser OAuth callbacks are derived from siteOrigin; successful sign-in uses the validated redirectPath, while an unknown identity goes to /signup and a provider error goes to /login. Register <siteOrigin>/api/auth/apple/callback for website Apple login and <siteOrigin>/api/auth/mobile/apple/callback for Android Apple login.

The preset accepts JSON bodies only with application/json or an application/*+json Content-Type. A present Origin header must exactly equal siteOrigin; clients such as native apps and server-to-server callers that do not send Origin remain supported. Provider-owned OAuth and Apple form-post callbacks are the only cross-origin exception.

registration remains application-owned, untrusted input. If it needs runtime validation, if paths or bodies differ, or if one provider needs different credentials from the fixed configuration above, define that specific application Route Handler with routeHandler instead. A specific App Router route takes precedence over the catch-all route.

Route Handler

routeHandler accepts either a cookie-aware BrowserOperation<T> or a cookie-free AuthResult<T>. It applies browser cookie mutations when present, sanitizes errors, maps authentication failures to HTTP status codes, serializes the standard JSON envelope, and adds Cache-Control: no-store.

The default status policy uses 400 for malformed inputs and password-policy failures, 401 for rejected local or provider credentials, 409 for existing identities, 502 for unavailable or malformed provider responses, and 500 for internal system failures. Core owns this framework-neutral semantic classification through authErrorCategory; the adapter maps it to HTTP. Applications can override the result with errorStatus.

import { routeHandler } from "gw-auth/nextjs";

const password = auth.password({ repository: passwordRepository }).browser();

export const POST = routeHandler(async (request) => {
  const input = await request.json();

  return password.login(validatedLoginInput(input));
});

OAuth routes may replace the default JSON success response while keeping the adapter-managed cookies:

import { routeHandler } from "gw-auth/nextjs";
import { NextResponse } from "next/server";

export const GET = routeHandler(
  async (request) => google.start({
    redirectPath: request.nextUrl.searchParams.get("redirectPath") ?? "/",
  }),
  {
    success: ({ authorizationUrl }) => NextResponse.redirect(authorizationUrl),
  },
);

For callbacks and other cookie-consuming operations, convert the request once:

import { nextRequestCookies, routeHandler } from "gw-auth/nextjs";

export const GET = routeHandler(async (request) => google.complete({
  code: request.nextUrl.searchParams.get("code") ?? "",
  state: request.nextUrl.searchParams.get("state") ?? "",
  cookies: nextRequestCookies(request),
}));

Mobile and password-recovery results use the same adapter without a wrapper:

// app/api/mobile/login/route.ts
export const POST = routeHandler(async () => mobilePassword.login(input));
// app/api/password-reset/request/route.ts
export const POST = routeHandler(async () => recovery.request({
  credentialId,
}));

Server Action

Call serverAction from an application-owned Server Action. It accepts either a BrowserOperation<T> or a cookie-free AuthResult<T>, writes any cookie mutations through Next.js cookies(), and returns a serializable { ok, value | error } result.

"use server";

import { serverAction } from "gw-auth/nextjs";

export async function loginAction(formData: FormData) {
  return serverAction(() => password.login({
    id: String(formData.get("id") ?? ""),
    password: String(formData.get("password") ?? ""),
  }));
}

The callback receives current cookie values for session operations:

export async function logoutAction() {
  return serverAction((cookies) => session.logout({ cookies }));
}

A cookie-free recovery or mobile operation needs no cookie argument:

export async function requestPasswordReset(credentialId: string) {
  return serverAction(() => recovery.request({ credentialId }));
}

Server auth and Proxy

Bind the shared session facade once when an application uses server auth in multiple places:

import { createAuthResolver } from "gw-auth/nextjs";

export const authResolver = createAuthResolver(auth.session);

The resolver exposes three normalized AuthResult<AuthState<TClaims>> lookup modes:

await authResolver.cookies({ refresh: false });
await authResolver.cookies();
await authResolver.request();

cookies({ refresh: false }) only verifies the access cookie and is safe during Server Component rendering. Refresh defaults to true, so cookies() may write replacement or cleanup cookies and is limited to Server Actions and Route Handlers. request() reads the current Next.js request headers for Route Handlers shared by browser and mobile clients. A present Authorization header must contain exactly one Bearer token, takes precedence over cookies, and never falls back to cookies after malformed or invalid bearer authentication. Without that header, request() uses the refresh-capable cookie path. It authenticates only; application Route Handlers remain responsible for authorization and Origin or CSRF enforcement.

Required for continuous browser sessions: getAuth and cookies({ refresh: false }) cannot rotate cookies during Server Component rendering. If authenticated pages should remain signed in beyond the access token lifetime, install the withAuth Proxy below. Without that writable boundary, a valid refresh cookie remains unused during page navigation and the Server Component reports a guest until a Route Handler or Server Action performs refresh.

Use getAuth when a Server Component needs verified access-token state. This replaces decoding a cookie directly in application code.

import { getAuth } from "gw-auth/nextjs";
import { AuthProvider } from "gw-auth/nextjs/client";

export default async function Layout({ children }: { children: React.ReactNode }) {
  const current = await getAuth(auth.session.browser());

  return (
    <AuthProvider initialAuth={current.isOk ? current.value : undefined}>
      {children}
    </AuthProvider>
  );
}

Use getAuthWithRefresh at the start of a Server Action or Route Handler that can write response cookies. It verifies the access cookie first, rotates the refresh session only when verification fails, applies replacement or terminal cleanup cookies, and returns normalized AuthState. Do not call it while rendering a Server Component because rendering cannot write cookies.

"use server";

import { getAuthWithRefresh } from "gw-auth/nextjs";

export async function updateProfile(formData: FormData) {
  const current = await getAuthWithRefresh(auth.session.browser());

  if (current.isErr) {
    return unauthorizedResult();
  }

  return updateUser(current.value.userId, formData);
}

Route Handlers use the same helper before executing protected application logic. Next.js attaches cookie writes made through cookies() to the returned response.

export async function POST(request: Request) {
  const current = await getAuthWithRefresh(auth.session.browser());

  if (current.isErr) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  return updateResource(request, current.value.userId);
}

Use withAuth as the writable refresh boundary for applications that render authenticated state in Server Components. It attempts refresh only for GET and HEAD requests, then redirects once to the same URL with rotated cookies before rendering continues. Its application-owned callback receives the verified access payload for optional optimistic routing. Server Actions and other mutations must authenticate and authorize again inside their own execution boundary.

import { NextResponse } from "next/server";
import { withAuth } from "gw-auth/nextjs";

export const proxy = withAuth(
  auth.session.browser(),
  () => NextResponse.next(),
);

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|.*\\..*).*)"],
};

Client

AuthProvider keeps server-resolved auth state synchronized and exposes authenticate and logout without owning endpoint paths. Both operations use authRequest, which sends cookies, disables caching, validates the adapter envelope (including non-OK HTTP responses), and returns the same Result style as core operations. It expects the default JSON response produced by routeHandler. Logout clears local state after any server-processed response, including an already-invalid refresh credential, because the browser logout operation still deletes its cookies. Network failures and invalid response envelopes preserve the existing local state.

"use client";

import type { AuthState } from "gw-auth/core";
import { authRequest, startOAuth, useAuth } from "gw-auth/nextjs/client";

const { auth, isAuthenticated, authenticate, logout } = useAuth();

const loggedIn = await authenticate("/api/auth/login", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ id, password }),
});

const current = await authRequest<AuthState>("/api/auth/session");

startOAuth("/api/auth/google?redirectPath=%2Fsettings");

Repository conformance tests

gw-auth/testing checks the security-sensitive behavior required from consumer-owned repositories without choosing an ORM, database, or user schema. Each assertion creates its own isolated fixture and exercises real concurrent calls:

import test from "node:test";
import {
  assertAccountDeletionRepositoryConformance,
  assertOAuthTransactionRepositoryConformance,
  assertPasswordResetRepositoryConformance,
  assertSessionRepositoryConformance,
  assertSocialRepositoryConformance,
} from "gw-auth/testing";

test("session repository", () => assertSessionRepositoryConformance(
  createIsolatedSessionFixture,
));

test("OAuth transaction repository", () =>
  assertOAuthTransactionRepositoryConformance(createIsolatedOAuthFixture));

test("social repository", () =>
  assertSocialRepositoryConformance(createIsolatedSocialFixture));

test("password-reset repository", () =>
  assertPasswordResetRepositoryConformance(createIsolatedPasswordResetFixture));

test("account-deletion repository", () =>
  assertAccountDeletionRepositoryConformance(createIsolatedAccountDeletionFixture));

Every factory must return a fresh repository namespace and may return a dispose callback. The social fixture also provides valid application registration data. Its assertion races both one token and two different tokens for the same provider identity. The password-reset fixture seeds one account, its active refresh sessions, and at least one unrelated user's active session. It exposes password-hash, target-session, and unrelated-session readers so the assertion can verify atomic completion without over-revocation. The session fixture verifies that one concurrent refresh rotates, the loser receives the persisted winner, and prior-token reuse after 10 seconds revokes the family. The account-deletion fixture seeds one active account with sessions and at least one Apple revocation. Its state reader lets the assertion verify atomic pending state, session revocation, idempotent provider completion, and final deletion ordering. This entry point is intended for Node.js test environments only.

Application boundary requirements

gw-auth validates credentials and protects token and attempt lifecycles, but it does not know an application's abuse policy. Every consuming HTTP boundary must rate-limit login, signup, password-reset request/completion, guest creation, OAuth starts/callbacks, social signup, refresh, and other credential-bearing operations using appropriate account, IP, device, and global controls. Keep application authorization and runtime validation of registration inputs at that same boundary.

Maintenance

Schedule cleanup according to traffic and retention policy:

await auth.session.deleteExpired();
await social.deleteExpiredSignupAttempts();
await oauthTransactionRepository.deleteExpiredOAuthTransactions(new Date());
await auth.guest({ repository: guestRepository }).deleteExpiredCredentials();
await recovery.deleteExpired();

See MIGRATION.md before upgrading. Release changes are listed in CHANGELOG.md. The package is licensed under the MIT License.