Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ APP_ORIGINS=http://localhost:3000

ISSUER=http://localhost:5312

# "web" for website to auth server, "server" for api server to auth server auth
AUTH_MODE=server

# Roles assigned to every new user
DEFAULT_ROLES=user,betaUser
# Roles that are allowed in the system
Expand Down Expand Up @@ -47,7 +44,7 @@ RATE_LIMIT=100
DELAY_AFTER=50

# SERVICE TOKENS
# Required when AUTH_MODE=server.
# Required for trusted server adapters and internal bearer validation.
API_SERVICE_TOKEN=32-byte-hex-string
# Optional dedicated secret for indexed refresh-token lookup fingerprints.
# If unset, the server falls back to API_SERVICE_TOKEN, and in development only
Expand Down
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ There are three token states worth keeping straight:
- Access token: signed JWT used for authenticated application access.
- Refresh token: opaque random token stored hashed in the `sessions` table.

Auth behavior depends on `AUTH_MODE`:
The API exposes a single bearer/JSON auth contract:

- `web`: access/refresh/ephemeral tokens are primarily stored in cookies.
- `server`: endpoints expect bearer tokens more often and return token payloads in JSON.
- Ephemeral, access, and refresh tokens are returned in JSON response payloads.
- Protected routes expect bearer credentials from a trusted server adapter or backend.
- The API does not set or read browser auth cookies.

Auth middleware is chosen centrally by [src/middleware/attachAuthMiddleware.ts](/Users/brandoncorbett/git/seamless-auth-api/src/middleware/attachAuthMiddleware.ts).

Expand Down Expand Up @@ -74,7 +75,7 @@ Direct provider wiring currently lives in [src/config/directMessaging.ts](/Users
- When adding or updating routes, use `schemas`, not `schema`, so request parsing and OpenAPI generation both work.
- If a route requires auth, prefer the `auth` option in `createRouter` definitions. That keeps middleware wiring and OpenAPI security metadata aligned.
- If a route also needs admin checks or rate limiting, combine `auth` with extra `middleware`.
- Be careful around cookie names and auth mode branching. Several flows have separate `web` and `server` response shapes.
- Be careful around token response shapes and bearer auth. Browser-cookie auth mode has been removed.
- Preserve existing local worktree changes unless the user explicitly asks you to clean them up.

## Before You Finish A Change
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ This repository does **not** assume any specific cloud provider, billing system,
## Why Seamless Auth API

- Passwordless-first design (no passwords to steal)
- Modern session handling using secure, HTTP-only cookies
- Bearer/JSON auth API with opaque refresh tokens and signed access tokens
- WebAuthn / passkeys support
- Optional WebAuthn PRF support for products that need browser-local key material
- Token and JWKS support for service-to-service auth
Expand Down Expand Up @@ -155,7 +155,7 @@ The browser/client flow is:
4. The provider redirects back to your `redirectUri` with `code` and `state`.
5. The client posts `{ code, state }` to `POST /oauth/:providerId/callback`.
6. Seamless Auth validates state, exchanges the code, fetches userinfo, links or creates the local
user, and issues the normal SeamlessAuth access/refresh session.
user, and issues the normal SeamlessAuth access/refresh JSON payload.

Example direct API start request:

Expand Down Expand Up @@ -316,7 +316,7 @@ You should receive a healthy response.
For production deployments:

- Use HTTPS
- Configure secure cookies
- Use a trusted server adapter or backend when exposing auth flows to browsers
- Rotate signing keys
- Back up your database
- Monitor authentication failures
Expand All @@ -332,7 +332,7 @@ Authentication infrastructure is security-sensitive.
For production deployments:

- Use HTTPS end-to-end
- Enable secure cookies (`Secure`, correct `SameSite`)
- Keep access and refresh tokens out of browser-readable storage
- Restrict CORS origins
- Rotate signing keys and secrets regularly
- Enable database backups and test restores
Expand Down
30 changes: 11 additions & 19 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ Global behavior is configured in [src/app.ts](/Users/brandoncorbett/git/seamless
- `helmet`
- JSON body parsing
- CORS
- cookie parsing
- request logging
- rate limiting and slow-down outside test mode
- development-only OpenAPI and Swagger UI
Expand Down Expand Up @@ -56,7 +55,7 @@ Behavior:
- finds or creates the user
- issues an ephemeral token
- optionally sends or returns phone OTP delivery info
- stores bootstrap token in a cookie when present
- stores bootstrap invite token hashes in challenge context when present

Registration does not itself create the long-lived session. It prepares the user for OTP, magic link, or WebAuthn completion.

Expand Down Expand Up @@ -115,28 +114,22 @@ Key concepts:

- refresh tokens are opaque random values stored only as bcrypt hashes
- access tokens are signed JWTs using the JWKS-managed signing key
- cookie auth can silently refresh sessions
- protected API routes require bearer authentication from a trusted server adapter
- session reuse detection revokes the replacement chain

When debugging auth bugs, inspect both the token code and the `sessions` table behavior. The middleware validates both JWT claims and backing session state.

## 4. Auth Modes
## 4. Auth Contract

`AUTH_MODE` changes the public contract of several endpoints.
The API exposes a single bearer/JSON auth contract.

### `web`
- login and registration continuation endpoints return ephemeral tokens in JSON
- session completion returns access and refresh tokens in JSON
- protected routes accept `Authorization: Bearer ...`
- refresh endpoints expect the raw opaque refresh token as bearer credentials

- tokens are primarily communicated via cookies
- cookie middleware is the normal auth path
- session issuance writes `seamless_access`, `seamless_refresh`, and `seamless_ephemeral`

### `server`

- more endpoints return token material in JSON
- bearer validation is used more heavily
- refresh endpoints expect bearer credentials rather than browser cookies

When modifying a controller that returns auth state, verify both branches. Many regressions in this codebase would only show up in one mode.
Browser-facing integrations should use a trusted server adapter that can translate application
sessions into SeamlessAuth bearer calls. The API no longer sets or reads browser auth cookies.

## 5. Config And Secrets

Expand Down Expand Up @@ -234,9 +227,8 @@ Practical guidance:

- Some routes declare auth through `middleware: [attachAuthMiddleware(...)]` instead of the `auth` field. That works at runtime, but OpenAPI security metadata is only added by the `auth` field today.
- `defineRoute` expects `schemas`, plural. Using `schema` silently skips request parsing and docs wiring.
- Cookie names in runtime code are `seamless_access`, `seamless_refresh`, and `seamless_ephemeral`. Confirm docs and tests against those exact names.
- `system_config` can mask env changes after first bootstrap because the DB value becomes authoritative.
- Silent refresh and refresh-token matching depend on scanning active sessions and comparing bcrypt hashes, so session-heavy scenarios are worth extra care.
- Refresh-token matching depends on indexed lookup fingerprints with a legacy bcrypt fallback, so session-heavy scenarios are worth extra care.

## 10. Suggested Workflow For Agents

Expand Down
25 changes: 0 additions & 25 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
"@types/bcrypt": "^6.0.0",
"base64url": "^3.0.1",
"bcrypt-ts": "^7.1.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"express": "^4.19.2",
"express-rate-limit": "^7.5.0",
Expand All @@ -69,7 +68,6 @@
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@eslint/js": "^9.25.1",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.24",
"@types/jsonwebtoken": "^9.0.6",
Expand Down
2 changes: 0 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
* See LICENSE file in the project root for full license information
*/

import cookieParser from 'cookie-parser';
import cors, { CorsOptions } from 'cors';
import express, { NextFunction, Request, Response } from 'express';
import helmet from 'helmet';
Expand Down Expand Up @@ -84,7 +83,6 @@ if (process.env.NODE_ENV !== 'test') {

app.use(express.json());
app.use(cors(corsOptions));
app.use(cookieParser());

app.use(logRoute);

Expand Down
24 changes: 2 additions & 22 deletions src/controllers/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import { Request, Response } from 'express';

import { getSystemConfig } from '../config/getSystemConfig.js';
import { clearAuthCookies, setAuthCookies } from '../lib/cookie.js';
import {
createRefreshTokenLookup,
generateRefreshToken,
Expand Down Expand Up @@ -37,7 +36,6 @@ import {
} from '../utils/utils.js';

const logger = getLogger('authentication');
const AUTH_MODE = process.env.AUTH_MODE;

export const login = async (req: Request, res: Response) => {
// For the initial login step, user either passes in an email or a phone number
Expand Down Expand Up @@ -181,12 +179,6 @@ export const login = async (req: Request, res: Response) => {
metadata: {},
});

if (AUTH_MODE === 'web') {
await setAuthCookies(res, { ephemeralToken: token });
res.status(200).json({ message: 'Success', loginMethods });
return;
}

const { access_token_ttl } = await getSystemConfig();
return res.status(200).json({
message: 'Success',
Expand Down Expand Up @@ -234,8 +226,6 @@ export const logout = async (req: Request, res: Response) => {
} catch (error) {
logger.error(`Error during logout: ${error}`);
await AuthEventService.log({ userId: authUser.id, type: 'logout_failed', req });
} finally {
clearAuthCookies(res);
}

return res.json({ message: 'Success' });
Expand All @@ -246,14 +236,10 @@ export const refreshSession = async (req: Request, res: Response) => {

let refreshToken: string | null = null;

if (AUTH_MODE === 'server' && req.headers.authorization?.startsWith('Bearer ')) {
if (req.headers.authorization?.startsWith('Bearer ')) {
refreshToken = req.headers.authorization.slice('Bearer '.length);
}

if (!refreshToken && AUTH_MODE === 'web') {
refreshToken = req.cookies?.seamless_refresh ?? null;
}

if (!refreshToken) {
logger.error('Refresh token provided is not of expected type for auth server configurations');
await AuthEventService.refreshTokenFailed(req, { reason: 'Missing refresh token' });
Expand Down Expand Up @@ -342,7 +328,7 @@ export const refreshSession = async (req: Request, res: Response) => {
const newSession = await Session.create({
userId: user.id,
infraId: session.infraId,
mode: session.mode,
mode: 'server',
organizationId: session.organizationId,
refreshTokenHash: newRefreshTokenHash,
refreshTokenLookup: newRefreshTokenLookup,
Expand All @@ -363,12 +349,6 @@ export const refreshSession = async (req: Request, res: Response) => {
);
await AuthEventService.log({ userId: user.id, type: 'refresh_token_success', req });

if (AUTH_MODE === 'web') {
await setAuthCookies(res, { accessToken: token, refreshToken: newRefreshToken });
res.status(200).json({ message: 'Success' });
return;
}

const { access_token_ttl, refresh_token_ttl } = await getSystemConfig();
return res.status(200).json({
message: 'Success',
Expand Down
1 change: 0 additions & 1 deletion src/controllers/internalSecurity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export const getSecurityAnomalies = async (_req: Request, res: Response) => {
try {
const FAILURE_TYPES = [
'login_failed',
'cookie_token_failed',
'bearer_token_failed',
'jwks_failed',
'mfa_otp_failed',
Expand Down
4 changes: 1 addition & 3 deletions src/controllers/magicLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import { hashDeviceFingerprint, hashSha256 } from '../utils/utils.js';
const logger = getLogger('magic-links');

const TTL_MINUTES = 15;
const AUTH_MODE: 'web' | 'server' = process.env.AUTH_MODE! as 'web' | 'server';

async function rejectDisabledMagicLink(req: Request, res: Response, userId?: string | null) {
const policy = await getLoginPolicy();
Expand Down Expand Up @@ -264,12 +263,11 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) {
},
req,
res,
authMode: AUTH_MODE,
clearBootstrap: true,
});

user.update({
lastLogin: new Date(),
challengeContext: null,
});

return;
Expand Down
4 changes: 0 additions & 4 deletions src/controllers/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ import {
} from '../services/oauthService.js';
import { issueSessionAndRespond } from '../services/sessionIssuance.js';

const AUTH_MODE: 'web' | 'server' = process.env.AUTH_MODE! as 'web' | 'server';

function allowedReturnTo(value: string | undefined, origins: string[]) {
if (!value) return undefined;
return origins.some((origin) => value.startsWith(origin)) ? value : undefined;
Expand Down Expand Up @@ -137,8 +135,6 @@ export async function finishOAuthLogin(req: Request, res: Response) {
},
req,
res,
authMode: AUTH_MODE,
clearExistingCookies: true,
});
} catch {
await AuthEventService.log({
Expand Down
9 changes: 0 additions & 9 deletions src/controllers/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import { Request, Response } from 'express';

import { getSystemConfig } from '../config/getSystemConfig.js';
import { setAuthCookies } from '../lib/cookie.js';
import { signAccessToken } from '../lib/token.js';
import { OrganizationMembership } from '../models/organizationMemberships.js';
import { Organization } from '../models/organizations.js';
Expand Down Expand Up @@ -145,14 +144,6 @@ export async function switchOrganization(req: Request, res: Response) {
await session.update({ organizationId: organization.id });
const token = await signAccessToken(session.id, user.id, user.roles, organization.id);

if (process.env.AUTH_MODE === 'web') {
await setAuthCookies(res, { accessToken: token });
return res.json({
message: 'Success',
organization: serializeOrganization(organization, membership),
});
}

const { access_token_ttl } = await getSystemConfig();
return res.json({
message: 'Success',
Expand Down
Loading
Loading