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
62 changes: 60 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Seamless Auth is an open source, passwordless authentication system designed to be embedded directly into applications.

It provides a small, explicit core and framework-specific adapters that make it easy to integrate secure, session-based authentication into APIs and web applications without redirects, third-party identity providers, or opaque middleware chains.
It provides a small, explicit core and framework-specific adapters that make it easy to integrate secure, session-based authentication into APIs and web applications without opaque middleware chains. Native passwordless flows stay embedded in your app, while optional OAuth routes let adopters add external identity providers when their product or enterprise customers need them.

This repository contains the core building blocks and official server-side framework integrations.

Expand All @@ -16,7 +16,7 @@ Seamless Auth is built around a few guiding principles:
Authentication is based on possession and verification, not shared secrets.

- **Embedded authentication**
Auth flows live inside your application. No redirects, callbacks, or hosted UI.
Native Seamless Auth flows live inside your application. OAuth redirects are available as an optional bridge to external identity providers.

- **Server-side session validation**
Sessions are managed using secure, HTTP-only cookies and validated by the API.
Expand All @@ -42,6 +42,7 @@ Responsibilities include:
- Verifying signed session cookies
- Authenticated server-to-server requests
- Resolving the current authenticated user
- Proxy-safe OAuth login helpers that never store provider access tokens
- Shared types and helpers

The core package does **not** depend on any specific web framework.
Expand Down Expand Up @@ -82,6 +83,8 @@ It is also the natural initializer boundary for adopter-supplied auth messaging

For WebAuthn PRF flows, the adapter proxies PRF registration query flags and assertion request bodies to the Seamless Auth API. PRF outputs remain browser-only and are never handled by the server adapter.

For OAuth flows, the adapter proxies provider discovery, OAuth start, and OAuth callback completion. The callback exchanges the provider authorization code at the private Seamless Auth API, then the adapter sets the normal signed access and refresh cookies for the application.

Location:

```
Expand All @@ -101,6 +104,61 @@ app.get(

---

## OAuth Login

OAuth support is intentionally split across the same trust boundary as the rest of Seamless Auth:

- the browser asks your app/API for enabled providers
- your server adapter proxies OAuth requests to the private Seamless Auth API
- Seamless Auth validates signed state, exchanges the provider code, links the provider identity, and issues a SeamlessAuth session
- the adapter stores only the resulting SeamlessAuth cookies

Provider access tokens are not stored by the adapter, returned to the frontend, or placed in cookies.

Mounted Express routes include:

- `GET /auth/oauth/providers`
- `POST /auth/oauth/:providerId/start`
- `POST /auth/oauth/:providerId/callback`

Example frontend sequence:

```ts
const start = await fetch("/auth/oauth/google/start", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
redirectUri: `${window.location.origin}/oauth/callback`,
returnTo: `${window.location.origin}/dashboard`,
}),
}).then((response) => response.json());

window.location.assign(start.authorizationUrl);
```

After the provider redirects back:

```ts
const params = new URLSearchParams(window.location.search);

await fetch("/auth/oauth/google/callback", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: params.get("code"),
state: params.get("state"),
}),
});
```

Configure providers on the Seamless Auth API using `oauth_providers` and `LOGIN_METHODS=...,oauth`.
Each provider references its client secret by environment variable name, for example
`clientSecretEnv: "GOOGLE_CLIENT_SECRET"`.

---

## Extensibility

Framework integrations are designed as thin adapters over the core package.
Expand Down
51 changes: 51 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ If you are building a custom adapter (Express, Fastify, Next.js, Hono, etc.), th
- Core authentication state machine
- Cookie validation and refresh logic
- Service-token–based API ↔ Auth Server communication
- Framework-agnostic OAuth provider discovery/start/callback helpers
- Stateless, cryptographically verifiable flows

This package **does not**:
Expand Down Expand Up @@ -87,6 +88,9 @@ Key exports include:
- `refreshAccessToken(...)` – rotates expired access sessions
- `verifyCookieJwt(...)` – verifies signed cookie payloads
- `createServiceToken(...)` – creates short-lived M2M assertions
- `listOAuthProvidersHandler(...)` – retrieves public OAuth provider metadata
- `startOAuthLoginHandler(...)` – starts an OAuth authorization-code login
- `finishOAuthLoginHandler(...)` – finishes OAuth login and returns cookie instructions

These functions return **descriptive results**, not HTTP responses.

Expand Down Expand Up @@ -121,6 +125,53 @@ if (result.type === "error") {

---

## OAuth Helper Flow

The core OAuth helpers are designed for framework adapters. They do not redirect, set cookies, or
read secrets. They proxy to the Seamless Auth API and return plain result objects that your adapter
turns into HTTP responses.

```ts
const providers = await listOAuthProvidersHandler({
authServerUrl: "https://auth.example.com",
});

const started = await startOAuthLoginHandler(
{
providerId: "google",
body: {
redirectUri: "https://app.example.com/oauth/callback",
returnTo: "https://app.example.com/dashboard",
},
},
{ authServerUrl: "https://auth.example.com" },
);

const finished = await finishOAuthLoginHandler(
{
providerId: "google",
body: {
code: "provider-code",
state: "signed-state-from-start",
},
},
{
authServerUrl: "https://auth.example.com",
accessCookieName: "seamless-access",
refreshCookieName: "seamless-refresh",
},
);

if (finished.setCookies) {
// adapter signs and applies access/refresh cookies
}
```

The Seamless Auth API handles state validation, provider token exchange, userinfo lookup, and
provider identity linking. Core and adapter code never store provider access tokens.

---

## Testing

All logic in this package is tested against compiled output (`dist/`),
Expand Down
134 changes: 134 additions & 0 deletions packages/core/src/handlers/oauthHandlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { authFetch } from "../authFetch.js";
import type { CookiePayload } from "../ensureCookies.js";
import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js";

export interface OAuthHandlerOptions {
authServerUrl: string;
cookieDomain?: string;
accessCookieName: string;
refreshCookieName: string;
}

export interface OAuthRequestInput {
providerId?: string;
body?: unknown;
forwardedClientIp?: string;
}

export interface OAuthHandlerResult {
status: number;
body?: unknown;
error?: unknown;
setCookies?: {
name: string;
value: CookiePayload;
ttl: number;
domain?: string;
}[];
}

export async function listOAuthProvidersHandler(
opts: Pick<OAuthHandlerOptions, "authServerUrl">,
) {
const up = await authFetch(`${opts.authServerUrl}/oauth/providers`, {
method: "GET",
});

const data = await up.json();

return {
status: up.status,
...(up.ok ? { body: data } : { error: data }),
};
}

export async function startOAuthLoginHandler(
input: OAuthRequestInput,
opts: Pick<OAuthHandlerOptions, "authServerUrl">,
) {
const up = await authFetch(
`${opts.authServerUrl}/oauth/${encodeURIComponent(input.providerId!)}/start`,
{
method: "POST",
body: input.body,
forwardedClientIp: input.forwardedClientIp,
},
);

const data = await up.json();

return {
status: up.status,
...(up.ok ? { body: data } : { error: data }),
};
}

export async function finishOAuthLoginHandler(
input: OAuthRequestInput,
opts: OAuthHandlerOptions,
): Promise<OAuthHandlerResult> {
const up = await authFetch(
`${opts.authServerUrl}/oauth/${encodeURIComponent(input.providerId!)}/callback`,
{
method: "POST",
body: input.body,
forwardedClientIp: input.forwardedClientIp,
},
);

const data = await up.json();

if (!up.ok) {
return {
status: up.status,
error: data,
};
}

const verifiedAccessToken = await verifySignedAuthResponse(
data.token,
opts.authServerUrl,
);

if (!verifiedAccessToken) {
throw new Error("Invalid signed response from Auth Server");
}

if (verifiedAccessToken.sub !== data.sub) {
throw new Error("Signature mismatch with data payload");
}

const sessionId =
typeof verifiedAccessToken.sid === "string"
? verifiedAccessToken.sid
: undefined;

return {
status: up.status,
body: data,
setCookies: [
{
name: opts.accessCookieName,
value: {
sub: data.sub,
...(sessionId === undefined ? {} : { sessionId }),
roles: data.roles,
email: data.email,
phone: data.phone,
organizationId: data.organizationId ?? null,
},
ttl: data.ttl,
domain: opts.cookieDomain,
},
{
name: opts.refreshCookieName,
value: {
sub: data.sub,
refreshToken: data.refreshToken,
},
ttl: data.refreshTtl,
domain: opts.cookieDomain,
},
],
};
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export * from "./handlers/verifyMagicLinkHandler.js";
export * from "./handlers/requestMagicLinkHandler.js";
export * from "./handlers/pollMagicLinkConfirmationHandler.js";
export * from "./handlers/switchOrganizationHandler.js";
export * from "./handlers/oauthHandlers.js";
Loading
Loading