Test your authentication integration
Test application decisions, persistent adapters, and browser flows separately. The package's protocol tests do not prove your registered callbacks, database transactions, reverse proxy, or app authorization rules are configured correctly.
Application routes
For an app that exports its token reader or client instance, stub the relevant
method with vi.spyOn from Vitest to exercise accepted tokens, rejected tokens,
and issuer failures. Avoid a global fetch stub that also intercepts unrelated
application traffic. For a protocol integration test, inject fetch through the
client/reader constructor or run the
local identity provider.
| Helper | Purpose |
|---|---|
createMemoryAuthorizationServer from /testing | Run the real protocol implementation with isolated in-memory services |
createFakeTenant from /testing | Stand in for a Udibo Identity tenant, served on a real socket |
createAuthenticatedTestSession from /hono/bff/testing | Register an access token and create a corresponding BFF session |
createTestSession from /hono/bff/testing | Seed a session for BFF session-endpoint tests |
createMockBffClient, MockOAuth2Provider from /react/testing | Render React behavior with controlled auth state |
A protected BFF request needs both a session and an access token accepted by the resource server. The following test assumes you have constructed an isolated app and its token service:
import { expect, test } from "vitest";
import { BasicScope } from "@udibo/oauth2/server";
import { createAuthenticatedTestSession } from "@udibo/oauth2/hono/bff/testing";
import type { HonoBff } from "@udibo/oauth2/hono/bff";
import type { TokenServiceInterface } from "@udibo/oauth2/server/authorization";
import type { Hono } from "hono";
declare const app: Hono;
declare const bff: HonoBff;
declare const tokenService: TokenServiceInterface<
{ id: string },
{ id: string }
>;
test("an authenticated user can read the protected route", async () => {
const cookie = await createAuthenticatedTestSession(bff, {
tokenService,
client: { id: "app" },
user: { id: "user-1" },
scope: new BasicScope("read"),
claims: { sub: "user-1" },
});
const response = await app.request("/api/me", {
headers: { cookie, [bff.csrfHeaderName!]: "1" },
});
expect(response.status).toBe(200);
await response.body?.cancel();
});Use the same token service that the app's resource server validates against. For a custom persistent session store, seed through that store's test setup. Also cover no session, insufficient scope, and a user trying to access another user's record. Keep the CSRF guard enabled in tests that claim to exercise browser credential handling.
An app built on a Udibo Identity tenant
An app that signs in against a Udibo Identity tenant reads more than a token:
the permissions claim, the organization the person picked at sign-in, the
caller's GET /api/memberships, and the tenant's answers to POST /api/check.
createFakeTenant answers all of them with the shapes a tenant uses, so the
app's own routes can be tested without the identity service. Serve it on a
loopback port, point the app's issuer at that origin, and decide who signs in:
import { once } from "node:events";
import type { AddressInfo } from "node:net";
import { serve } from "@hono/node-server";
import { createFakeTenant } from "@udibo/oauth2/testing";
const server = serve({
fetch: (request) => tenant.fetch(request),
hostname: "127.0.0.1",
port: 0,
});
if (!server.listening) await once(server, "listening");
const { port } = server.address() as AddressInfo;
const tenant = await createFakeTenant({ issuer: `http://127.0.0.1:${port}` });
await tenant.addClient({
id: "my-app",
secret: "test-secret",
redirectUris: ["http://localhost:8000/auth/callback"],
});
await tenant.addUser({ id: "ada", username: "ada" });
tenant.addOrganization({ id: "org-acme", slug: "acme" });
tenant.addMember("org-acme", "ada", { permissions: ["projects:archive"] });
tenant.signInAs("ada", { organizationId: "org-acme" });
server.close();The next authorization request authenticates as whoever signInAs named, with
no hosted page and no consent step; a credential keeps the organization it was
issued in across refreshes, and stops answering for it once removeMember ends
the membership. Register a resource type and add grants to exercise
resource-level answers, or register the client with accessTokenFormat: "jwt"
and an audience to test a resource server that validates locally against the
tenant's JWKS. issueAccessToken mints a token directly, for an API test that
does not need the browser flow. Introspection answers a confidential client, one
registered with a secret, only about the tokens issued to it; any other caller
gets active: false, as from a tenant.
The fake also answers the two APIs an app calls with the signed-in person's own token:
The organization API under
/api/organizations. It creates an organization owned by its creator, lists the caller's organizations and members, and handles renames, deletes, invitations, offers and accepting them, and revoking a tier. Any member can read the organization and its members. Anowneroradminmanages it, and anyone below that gets the same404on those actions, whatever the reason. Anadminasking to delete the organization, or to offer or revoke theownertier, gets403. An invitation to the address of someone already in the tenant becomes a pending membership. Any other address gets an invitation, which only the verified holder of that address can accept.defineOrganizationRoleadds a tenant-wide role beside the built-in tiers and returns its id. Invitations may offer it by slug,member-roleslists it with that id, and a manager grants it to an accepted member throughPOST …/members/:userId/roles, lists what a member holds withGET …/members/:userId/roles, and takes it away withDELETE …/members/:userId/roles/:roleId. The role'spermissionsthen answer inside that organization only. When a manager revokes someone's last accepted role in an organization, the membership ends with everything it carried there: the application roles, the permissionsaddMemberseeded, and the offers still open to them, pending memberships and invitations to their address alike. Rejoining grants only what the new offer names.The account API under
/api/account: the person's own metadata bucket, their login sessions, and their linked accounts. Every authorization request is a sign-in that starts a new login session and sets a cookie on the tenant's origin naming it. A request that sends the cookie back, as the same browser does, continues that session while it is live and belongs to the personsignInAschose, so a person has as many devices as browsers. Name a new session's device withsignInAs(id, { userAgent, ipAddress }), seed metadata withuserMetadata, add linked accounts withlinkAccount, and sethasPassword: falseto test the refusal to disconnect someone's last way in.
It also answers the app's own machine credential, the way a tenant answers a bot
the app runs. Register a client with grants: ["client_credentials"], a
secret, and a scopes allowlist. A tenant requires the allowlist of such an
application, and addClient refuses to go without it. Only a confidential
client may use that grant (RFC 6749 §4.4), so one registered without a secret is
refused 401 invalid_client. The token endpoint issues a confidential one a
token that names no person, with no refresh token; its scope vocabulary is
identity:organizations:read and identity:organizations:write, narrowed by
that allowlist, and any other scope, an OIDC scope included, is refused
invalid_scope. An organization_id is accepted on the refresh_token grant
alone. That token is refused 403 wherever the tenant answers for a person, and
401 at UserInfo. Register the client with
machinePermissions: ["resource_grants.read"], as a tenant administrator would
assign it, and GET /api/resource-grants?type=…&id=… under its token lists who
holds a grant on that one resource, each row naming its holder and its role. A
grant's role is the slug it is listed under, with the name
defineOrganizationRole gave it; it defaults to a slug built from the grant's
permissions, and a built-in tier is listed as builtInRole with no roleId. A
person's token is refused 403 there, and a machine token without the
permission or the identity:organizations:read scope gets 404.
A login session and the credentials issued from it are tied together only for a
first-party application, which is what addClient registers unless you pass
type: "third-party". For a first-party application:
Signing in again in the same browser revokes the credentials that session issued before.
Revoking one of its tokens at the revocation endpoint, as a sign-out does, ends the session.
Ending the session revokes its credentials.
A third-party application's credentials only name the session they came from as the caller's current one. Revoking them leaves the session alone, and they outlive it.
The same contract suite runs these answers against the fake and against the real
identity service, so both give the same shapes and refusals. The fake still does
not do everything a tenant does. It has no hosted pages and no management API.
Its organization API grants members only tenant-wide roles, and nobody in it
holds a built-in role as an application role, so revoking one is refused 403.
It does not let an organization define its own roles. It lists resource grants
but does not place or revoke them over HTTP. It has no policy: no MFA, no
lockout, no rate limits and no session limits. Test those against a real tenant.
Persistent storage contracts
Use the exported suites against an isolated database or equivalent real store.
They register describe and it blocks through Vitest,
an optional peer dependency of the package, so call them from a Vitest test
file:
import { runOtpStoreContractTests } from "@udibo/oauth2/testing/contract";
import type { OtpStore } from "@udibo/oauth2/identity";
declare function freshOtpStore(): Promise<OtpStore>;
runOtpStoreContractTests({
describeName: "Application OTP store",
makeStore: freshOtpStore,
});Each makeStore must provide fresh state. The OTP suite requires a successful
consume to retire the pair's older codes, and checks it through
EmailOtpService: after two overlapping requests, signing in with the newest
code must leave the older one invalid. Register a store that keeps one code per
(email, purpose), where create replaces the pair's code, with
oneActiveCode: true. The suite then requires that replacement and skips the
cases that need two codes active at once. A store that keeps several codes and
sets the option fails. Follow the suite's options for the other interfaces: tokens, authorization codes, device codes, MFA, rate limits,
lockout, token flows, token readers, and pending login requests
(AuthRequestStorage, whose optional take is raced by eight concurrent
callers). Register an AuthRequestStorage shared between users with
clear: "scoped" when its clear() removes only expired records: the suite
then requires clear() to leave every in-progress sign-in intact and to remove
an expired record, instead of removing everything. The BFF session suite lives
in @udibo/oauth2/hono/bff/testing. It fails a store whose withRefreshLock
passes no RefreshLease, since a holder that lost an expiring lock could then
overwrite or end a session another instance refreshed. Set
refreshLockNeverExpires: true only when no holder can lose the lock while its
refresh runs.
The MFA and lockout suites act on users user-1 and user-2 unless you pass
userId and otherUserId. The token-flow suite issues tokens for subject
and otherSubject the same way. When your rows reference a users table, pass
the ids of two users that exist. Token hashes are unique to each test, and OTP
record ids are crypto.randomUUID() values, unknown ones included, so a unique
hash column or a uuid id column accepts them. The MFA suite checks recovery
codes only for a user with an active credential, the only user MfaService
issues them to.
runTokenServiceContractTests and runTokenRotationContractTests build each
rotation family's id with crypto.randomUUID(), so a store with a uuid family
column runs every family case, and each case sees only its own families. Pass
makeFamilyId to either suite for another id format, and supportedScope when
acceptedScope accepts only a fixed vocabulary:
import type { ClientInterface } from "@udibo/oauth2/server";
import type {
ClientServiceInterface,
TokenServiceInterface,
UserServiceInterface,
} from "@udibo/oauth2/server/authorization";
import { runTokenServiceContractTests } from "@udibo/oauth2/testing/contract";
interface AppClient extends ClientInterface {
id: string;
}
interface AppUser {
id: string;
username: string;
}
declare function freshTokenServices(): Promise<{
userService: UserServiceInterface<AppUser>;
clientService: ClientServiceInterface<AppClient, AppUser>;
tokenService: TokenServiceInterface<AppClient, AppUser>;
}>;
declare function addUser(
service: UserServiceInterface<AppUser>,
user: AppUser,
password: string,
): Promise<void>;
declare function addClient(
service: ClientServiceInterface<AppClient, AppUser>,
client: AppClient,
): Promise<void>;
runTokenServiceContractTests<AppClient, AppUser>({
makeServices: freshTokenServices,
addUser,
addClient,
supportedScope: "openid",
});A token service without rotate must keep a revoked family revoked: the suite
saves a successor after revokeFamily and requires it to be stored already
revoked.
The identity user suite covers IdentityUserStore, separately from the OAuth2
UserServiceInterface. Its fixture supplies your application's valid sign-up
profiles and deterministic lookup keys. Explicitly enable the optional
capabilities you use; a disabled capability is not checked:
import {
type IdentityUserStoreContractFixture,
type ListableSessionServiceContractFixture,
runIdentityUserStoreContractTests,
runListableSessionServiceContractTests,
runRevocableSessionServiceContractTests,
} from "@udibo/oauth2/testing/contract";
interface AppUser {
id: string;
}
declare function freshUsers(): Promise<
IdentityUserStoreContractFixture<AppUser>
>;
declare function freshSessions(): Promise<
ListableSessionServiceContractFixture
>;
runIdentityUserStoreContractTests({
makeFixture: freshUsers,
replaceCredential: true,
emailVerification: true,
legacyCredentials: true,
resetCredential: true,
});
runRevocableSessionServiceContractTests({ makeFixture: freshSessions });
runListableSessionServiceContractTests({ makeFixture: freshSessions });Supply unknownUserId as a valid absent id for your user store. Session
fixtures supply two distinct existing owners (userId and otherUserId) and
deterministic, valid non-secret ids through sessionId(sequence). This lets
UUID-backed implementations use their real foreign-key owners and id format.
Each fixture is fresh for each test and may provide dispose() to close its
connections and delete its records. For credential replacement, supply
removeCredential to seed a user without a native credential. For email
verification, supply setEmail and isEmailVerified; for imported credentials,
supply setLegacyCredential. For the transactional password reset, supply
startSession, isSessionLive and failNextReset. The suite injects a failure
at the credential step, at the session step and, with legacyCredentials, at
the imported-hash step in turn, and requires that no write survives any of them.
Make failNextReset throw after the transaction's other writes have run, not
before them, or the check cannot tell an atomic store from one that writes
nothing before failing. These setup helpers manipulate your own store, not
an in-memory substitute for the implementation being verified. The suite checks
all stored credential fields, a read after a lost compare-and-set, and
concurrent replacement winners. Email verification must not mark the current
address verified using a token issued for an earlier address.
The identity session suites exercise RevocableSessionService and the optional
ListableSessionService; they are separate from the BFF's SessionStore suite.
Translate addSession's live, revoked, expired, and idle-timed-out states into
your application's storage and clock policy. isLive must observe whether your
application still accepts that session after revocation returns. Listing must
exclude ended and foreign sessions, sort by latest activity, and expose only the
documented display fields. Run both suites for a store with both capabilities.
These tests cover observed concurrency scenarios; they do not prove every
possible database interleaving or validate your application's normalization and
sign-up policy.
A token service that implements rotate also runs
runTokenRotationContractTests. It races rotate against itself, against
revokeFamily, and through the refresh grant against a replay, and fails a
rotation partway. Your store's test build awaits barriers.afterClaim() inside
rotate, between its claim and its insert, so the suite can hold a rotation at
that point; a store that never reaches the barrier fails every held case. Set
rotateAwaitsBarrier: false only when rotate never awaits between its claim
and its insert, as in MemoryTokenService. The suite then overlaps whole
calls, which proves nothing about a gap inside rotate:
import type { ClientInterface } from "@udibo/oauth2/server";
import type {
ClientServiceInterface,
TokenServiceInterface,
UserServiceInterface,
} from "@udibo/oauth2/server/authorization";
import {
runTokenRotationContractTests,
type TokenRotationBarriers,
} from "@udibo/oauth2/testing/contract";
interface AppClient extends ClientInterface {
id: string;
}
interface AppUser {
id: string;
username: string;
}
declare function freshTokenServices(barriers: TokenRotationBarriers): Promise<{
userService: UserServiceInterface<AppUser>;
clientService: ClientServiceInterface<AppClient, AppUser>;
tokenService: TokenServiceInterface<AppClient, AppUser>;
}>;
declare function addUser(
service: UserServiceInterface<AppUser>,
user: AppUser,
password: string,
): Promise<void>;
declare function addClient(
service: ClientServiceInterface<AppClient, AppUser>,
client: AppClient,
): Promise<void>;
runTokenRotationContractTests<AppClient, AppUser>({
makeServices: freshTokenServices,
addUser,
addClient,
});runTokenServiceContractTests checks that revokeCode(code, client) revokes
nothing for a client other than the one the code's tokens were issued to, and
that it ends the refresh tokens rotated from the code's tokens while leaving
other families live and keeping those families revoked, so a successor saved
afterwards is not live; runTokenRotationContractTests checks the same against
a rotation in flight. It also checks that revokeCode(code, client, codeVerifierHash) revokes nothing for a hash other than the
codeVerifierHash the code's tokens were saved with, or for tokens saved
without one, and revokes for the matching hash. Set revokeCodeChecksClient: false or revokeCodeChecksVerifier: false only for a store that deliberately
ignores that argument; the suite then registers an ignored case naming the
gap. Pass the same revokeCodeChecksVerifier: false to
runCodeExchangeContractTests, which still checks that exchangeCode's token
revokes for its own verifier.
A token service that implements exchangeCode also runs
runCodeExchangeContractTests. It races exchangeCode against itself and,
through the authorization code grant, against a replay of the code, and fails
an exchange partway. The same barrier rule applies: your store's test build
awaits barriers.afterClaim() inside exchangeCode, between claiming the code
and inserting the token, and only a store that never awaits between them sets
exchangeAwaitsBarrier: false, as MemoryTokenService does:
import type { ClientInterface } from "@udibo/oauth2/server";
import type {
AuthorizationCodeServiceInterface,
ClientServiceInterface,
TokenServiceInterface,
UserServiceInterface,
} from "@udibo/oauth2/server/authorization";
import {
type CodeExchangeBarriers,
runCodeExchangeContractTests,
} from "@udibo/oauth2/testing/contract";
interface AppClient extends ClientInterface {
id: string;
}
interface AppUser {
id: string;
username: string;
}
declare function freshCodeStores(barriers: CodeExchangeBarriers): Promise<{
userService: UserServiceInterface<AppUser>;
clientService: ClientServiceInterface<AppClient, AppUser>;
authorizationCodeService: AuthorizationCodeServiceInterface<
AppClient,
AppUser
>;
tokenService: TokenServiceInterface<AppClient, AppUser>;
}>;
declare function addUser(
service: UserServiceInterface<AppUser>,
user: AppUser,
password: string,
): Promise<void>;
declare function addClient(
service: ClientServiceInterface<AppClient, AppUser>,
client: AppClient,
): Promise<void>;
runCodeExchangeContractTests<AppClient, AppUser>({
makeServices: freshCodeStores,
addUser,
addClient,
});In addition to the shared contracts, test your adapter's transaction boundaries:
Two consumers of one OTP/code/refresh token cannot both succeed.
A refresh completing after logout cannot recreate a revoked session.
A password upgrade cannot replace a credential changed by a reset.
A competing token save cannot restore a revoked refresh-token family.
Records from another application or user cannot satisfy an app-specific lookup.
A contract suite checks its scenarios, not every isolation failure your database can exhibit. Close connections and dispose request bodies so test resource sanitizers remain enabled.
Browser verification
Use a real browser for callback cookies, CSRF, and redirect behavior. Start login and finish it in the same browser; attempt a mismatched callback as a refusal case. Test production cookie/proxy settings on HTTPS and repeat with requests reaching different app instances. The external-auth example and local provider support this without a hosted account.
Package contributor checks
To change this package itself, follow CONTRIBUTING.md for its checks and test suites.

