@structure-ai/auth
v0.0.9
Published
Tenant-aware authentication primitives for passwords, magic links, passkeys, OAuth, and revocable sessions.
Downloads
774
Readme
@structure-ai/auth
Tenant-aware authentication for Bun/Effect applications without an external auth or cryptography dependency. It provides the complete password lifecycle, email magic links, opaque sessions, passkeys, OAuth 2/OIDC providers, a Web Request handler, and application-owned ports for storage, delivery, rate limits, audit, policy, and provider extensions.
Authentication is the boundary here. Application profiles, permissions, roles, and resource authorization remain in their owning bounded contexts.
Quick start
import {
allowAllRateLimiter,
inMemoryAuthStore,
makeAuth,
makeAuthHandler,
} from "@structure-ai/auth";
import { Effect, Redacted } from "effect";
const memory = inMemoryAuthStore(); // development/tests only
const auth = makeAuth({
store: memory.store,
resolveTenant: (tenantId) =>
Effect.succeed({
baseUrl: new URL(`https://${tenantId}.example.com`),
passkey: {
rpId: `${tenantId}.example.com`,
rpName: "Example",
origins: [`https://${tenantId}.example.com`],
},
oauth: {
google: {
clientId: "google-client-id",
clientSecret: Redacted.make("loaded-from-secret-config"),
},
},
}),
emailSender: {
send: (message) => sendWithApplicationMailer(message),
},
rateLimiter: applicationRateLimiter,
audit: applicationAuditSink,
});
const { handler } = await Effect.runPromise(
makeAuthHandler(auth, {
// Resolve tenant from trusted host/routing data, never a JSON body field.
resolveTenant: (request) => resolveTenantFromHost(request),
}),
);allowAllRateLimiter is exported for tests and local prototypes. Production composition must provide a durable/shared limiter appropriate to its topology.
Capabilities
| Capability | Contract |
| --- | --- |
| Password | Registration, mandatory email verification, sign-in/out, change, forgotten-password reset, and all-session revocation. Bun password performs Argon2id off the main thread (defaults: 64 MiB, 3 iterations). |
| Magic link | Enumeration-safe request response, expiring single-use token, verified account provisioning, opaque session creation. |
| Sessions | 256-bit opaque bearer token returned as Redacted; only SHA-256 digests enter storage. Expiry, individual revocation, all-user revocation, and secure cookie helpers. |
| Passkey | Registration/authentication ceremonies; strict challenge, type, origin, RP ID hash, user-presence/user-verification, signature, and counter validation. Supports ES256, RS256, and Ed25519. |
| OAuth | Authorization code + S256 PKCE + single-use state. Built-in Google, GitHub, X, and LinkedIn definitions; injected bounded HTTP client and provider resolver. |
| Multi-tenancy | Tenant ID scopes users, emails, identities, tokens, sessions, challenges, passkeys, and provider configuration. |
| Extension policy | Custom AuthStore, EmailSender, RateLimiter, AuthAuditSink, AccountLinkPolicy, OAuthHttpClient, OAuthProviderResolver, password hasher, clock/random/token primitives, and HTTP origin policy. |
Core workflows
Every method returns an Effect with classified tagged errors. Tokens and session cookies are Redacted at the API boundary.
const program = Effect.gen(function* () {
const pending = yield* auth.registerPassword({
tenantId: "acme",
email: "[email protected]",
password: "a long application-approved password",
});
// Token arrives through EmailSender, then the client submits it.
const verified = yield* auth.verifyEmail("acme", verificationToken);
const session = yield* auth.signInPassword(
"acme",
"[email protected]",
"a long application-approved password",
);
const registration = yield* auth.beginPasskeyRegistration("acme", session.token);
// navigator.credentials.create({ publicKey: registration }) in the browser
yield* auth.finishPasskeyRegistration("acme", session.token, browserResponse);
const oauth = yield* auth.beginOAuth("acme", "github", "/settings");
// Redirect to oauth.authorizationUrl. The callback submits state + code.
return { pending, verified };
});Password reset and magic-link request methods deliberately return no account-existence signal. A successful password reset or change revokes every older session and returns a fresh one.
HTTP routes
makeAuthHandler validates its route table at construction and returns Effect<AuthHandler, InvalidAuthRoutes>. It accepts JSON bodies up to 64 KiB, checks mutation origins, resolves the tenant through the caller, maps errors without internal causes, and sets Cache-Control: no-store. Routes default to the /auth namespace (basePath moves it).
| Route id | Method | Default path |
| --- | --- | --- |
| registerPassword | POST | /auth/register/password |
| verifyEmail | POST | /auth/verify-email |
| requestEmailVerification | POST | /auth/email-verification/request |
| signInPassword | POST | /auth/sign-in/password |
| signOut | POST | /auth/sign-out |
| getSession | GET | /auth/session |
| requestPasswordReset | POST | /auth/password/reset/request |
| resetPassword | POST | /auth/password/reset/complete |
| changePassword | POST | /auth/password/change |
| requestMagicLink | POST | /auth/magic-link/request |
| consumeMagicLink | POST | /auth/magic-link/consume |
| oauthStart | POST | /auth/oauth/:provider/start |
| oauthCallback | GET | /auth/oauth/:provider/callback |
| passkeyRegisterOptions | POST | /auth/passkeys/register/options |
| passkeyRegisterVerify | POST | /auth/passkeys/register/verify |
| passkeyAuthenticateOptions | POST | /auth/passkeys/authenticate/options |
| passkeyAuthenticateVerify | POST | /auth/passkeys/authenticate/verify |
Route overrides
routes remaps individual paths through stable route ids. Values are absolute paths: an overridden route is served at exactly that path (its HTTP method unchanged) and leaves the base namespace; every other route keeps its default. oauthStart and oauthCallback overrides must contain exactly one :provider segment; all other routes accept literal paths only.
const { handler } = await Effect.runPromise(
makeAuthHandler(auth, {
resolveTenant: (request) => resolveTenantFromHost(request),
basePath: "/api/auth",
routes: {
signInPassword: "/login",
oauthStart: "/login/oauth/:provider/start",
},
}),
);Invalid shapes, unknown ids, and same-method path collisions (a :provider segment matches any single segment, including defaults) fail construction with every violation aggregated in InvalidAuthRoutes — overrides never shadow each other silently at runtime. Renaming paths changes neither cookie paths nor the origin/tenant/error envelope. Route ids are contract-stable; renaming an id is a breaking change.
Magic-link/reset/verification emails land on application pages. Those pages POST the token to the matching endpoint so link scanners do not consume credentials merely by fetching a URL.
Persistence contract
AuthStore is application-owned. Its compound mutation methods are intentional transaction boundaries:
createPasswordUserandcreateOAuthUseratomically enforce tenant-scoped user/email/identity uniqueness.consumeOneTimeToken,consumeOAuthState, andconsumePasskeyChallengeatomically remove a value before returning it, including when expired.replacePasswordAndRevokeSessionschanges the hash and removes all sessions in one transaction.addOAuthIdentityandaddPasskeyenforce tenant-scoped credential uniqueness.- counters may only be updated after successful signature verification.
A durable adapter must preserve those semantics and may fail with AuthStoreError; it must never store raw one-time/session tokens or OAuth client secrets. @structure-ai/auth-sqlite and @structure-ai/auth-pg provide Bun-native implementations with explicit schema migration functions. inMemoryAuthStore is deterministic enough for local development and tests, but is neither durable nor a cross-instance rate limiter.
Account linking
The default denyAccountLinking never joins accounts by email. A new provider identity matching an existing verified email fails with AccountLinkDenied. An application that wants linking supplies AccountLinkPolicy.authorize; the request includes the tenant, provider subject, verified provider profile, target user, and authenticated requesting user when present.
OAuth profiles without email are supported (notably X). Unverified provider email does not claim the tenant's email uniqueness key.
Passkey limits
- Registration accepts
noneattestation and packed self-attestation. Certificate-backed/basic/enterprise attestation and every other format fail closed. - COSE algorithms are limited to ES256, RS256, and Ed25519. The parser rejects indefinite CBOR, unsafe lengths, excessive nesting, malformed keys, and mismatched algorithms.
- User verification is required by default. Applications may explicitly make it preferred per tenant.
- Zero-only counters are accepted for authenticators without counters; a positive stored counter must increase.
- RP IDs and exact allowed origins are tenant configuration, never client input.
Security and operations
baseUrlmust use HTTPS excepthttp://localhost/127.0.0.1for development.- OAuth secrets, access tokens, codes, session tokens, and email tokens are
Redactedand excluded from audit events and errors. - Rate-limiter keys are SHA-256 digests; email addresses and tokens are not sent to the limiter.
AuthAuditSinkreceives successful security state transitions with stable action/user/provider fields only and must absorb its own delivery failures. Record rejected requests at the owning edge if required by application policy.- Provider calls default to a 10-second timeout and reject non-success, malformed, or responses larger than 1 MiB. No retries occur inside auth.
- OAuth endpoints and provider response contracts can change. Keep provider conformance tests and review upstream changes before deployment.
- Session and token cleanup, credential retention/deletion, backup/restore, encryption at rest, mail reputation, and rate-limit capacity are responsibilities of the durable application adapter and its runbooks.
Exports
| Export group | Purpose |
| --- | --- |
| makeAuth, AuthService, MakeAuthOptions | Main Effect workflow service. |
| makeAuthHandler, AuthHandlerOptions, AuthRouteId, AuthRouteViolation | Web-standard transport adapter with configurable route paths. |
| AuthStore, inMemoryAuthStore | Persistence port and development/test adapter. |
| argon2id, PasswordHasher | Bun Argon2id implementation and replacement port. |
| OAuthProvider*, builtInOAuthProvider, fetchOAuthHttpClient | Provider definitions, tenant resolver, exchange/profile engine, HTTP port. |
| verifyPasskeyRegistration, verifyPasskeyAuthentication | Strict WebAuthn/COSE verification used by the service. |
| RateLimiter, AuthAuditSink, AccountLinkPolicy, EmailSender | Application policy and side-effect ports. |
| Auth*Error, InvalidAuthRoutes, RateLimitExceeded, UnsupportedPasskey | Classified safe failures. |
See test/ for executable password, magic-link, OAuth, passkey, rate-limit/audit, and HTTP examples.
