@seamless-auth/express 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # @seamless-auth/server-express
2
+
3
+ Drop-in Express adapter for Seamless Auth “server mode” authentication.
4
+
5
+ ```js
6
+ import express from "express";
7
+ import createSeamlessAuthServer from "@seamless-auth/server-express";
8
+
9
+ const app = express();
10
+ app.use("/auth", createSeamlessAuthServer({
11
+ authServerUrl: process.env.AUTH_SERVER_URL,
12
+ cookieDomain: ".myapp.com"
13
+ }));
@@ -0,0 +1,3 @@
1
+ import { Router } from "express";
2
+ import type { SeamlessAuthServerOptions } from "./types";
3
+ export declare function createSeamlessAuthServer(opts: SeamlessAuthServerOptions): Router;
@@ -0,0 +1,128 @@
1
+ import express from "express";
2
+ import cookieParser from "cookie-parser";
3
+ import { setSessionCookie, clearAllCookies, clearSessionCookie, } from './internal/cookie.js';
4
+ import { authFetch } from './internal/authFetch.js';
5
+ import { createEnsureCookiesMiddleware } from './middleware/ensureCookies.js';
6
+ import { verifySignedAuthResponse } from './internal/verifySignedAuthResponse.js';
7
+ export function createSeamlessAuthServer(opts) {
8
+ const r = express.Router();
9
+ r.use(express.json());
10
+ r.use(cookieParser());
11
+ const { authServerUrl, cookieDomain = "", accesscookieName = "seamless-auth-access", registrationCookieName = "seamless-auth-registration", refreshCookieName = "seamless-auth-refresh", preAuthCookieName = "seamless-auth-pre-auth" } = opts;
12
+ const proxy = (path, method = "POST") => async (req, res) => {
13
+ try {
14
+ const response = await authFetch(req, `${authServerUrl}/${path}`, { method, body: req.body });
15
+ res.status(response.status).json(await response.json());
16
+ }
17
+ catch (error) {
18
+ console.error(`Failed to proxy to route. Error: ${error}`);
19
+ }
20
+ };
21
+ r.use(createEnsureCookiesMiddleware({
22
+ authServerUrl,
23
+ cookieDomain,
24
+ accesscookieName,
25
+ registrationCookieName,
26
+ refreshCookieName,
27
+ preAuthCookieName
28
+ }));
29
+ r.post("/webAuthn/login/start", proxy("webAuthn/login/start"));
30
+ r.post("/webAuthn/login/finish", finishLogin);
31
+ r.get("/webAuthn/register/start", proxy("webAuthn/register/start", "GET"));
32
+ r.post("/webAuthn/register/finish", finishRegister);
33
+ r.post("/otp/verify-phone-otp", proxy("otp/verify-phone-otp"));
34
+ r.post("/otp/verify-email-otp", proxy("otp/verify-email-otp"));
35
+ r.post("/login", login);
36
+ r.post("/registration/register", register);
37
+ r.post("/logout", logout);
38
+ r.get("/users/me", me);
39
+ return r;
40
+ async function login(req, res) {
41
+ const up = await authFetch(req, `${authServerUrl}/login`, {
42
+ method: "POST",
43
+ body: req.body,
44
+ });
45
+ const data = (await up.json());
46
+ if (!up.ok)
47
+ return res.status(up.status).json(data);
48
+ const verified = await verifySignedAuthResponse(data.token, authServerUrl);
49
+ if (!verified) {
50
+ throw new Error("Invalid signed response from Auth Server");
51
+ }
52
+ if (verified.sub !== data.sub) {
53
+ throw new Error("Signature mismatch with data payload");
54
+ }
55
+ setSessionCookie(res, { sub: data.sub }, cookieDomain, data.ttl, preAuthCookieName);
56
+ res.status(204).end();
57
+ }
58
+ async function register(req, res) {
59
+ const up = await authFetch(req, `${authServerUrl}/registration/register`, {
60
+ method: "POST",
61
+ body: req.body,
62
+ });
63
+ const data = (await up.json());
64
+ if (!up.ok)
65
+ return res.status(up.status).json(data);
66
+ setSessionCookie(res, { sub: data.sub }, cookieDomain, data.ttl, registrationCookieName);
67
+ res.status(200).json(data).end();
68
+ }
69
+ async function finishLogin(req, res) {
70
+ const up = await authFetch(req, `${authServerUrl}/webAuthn/login/finish`, {
71
+ method: "POST",
72
+ body: req.body,
73
+ });
74
+ const data = (await up.json());
75
+ if (!up.ok)
76
+ return res.status(up.status).json(data);
77
+ const verifiedAccessToken = await verifySignedAuthResponse(data.token, authServerUrl);
78
+ const verifiedRefreshToken = await verifySignedAuthResponse(data.refreshToken, authServerUrl);
79
+ if (!verifiedAccessToken || !verifiedRefreshToken) {
80
+ throw new Error("Invalid signed response from Auth Server");
81
+ }
82
+ if (verifiedAccessToken.sub !== data.sub || verifiedRefreshToken.sub !== data.sub) {
83
+ throw new Error("Signature mismatch with data payload");
84
+ }
85
+ setSessionCookie(res, { sub: data.sub, roles: data.roles }, cookieDomain, data.ttl, accesscookieName);
86
+ setSessionCookie(res, { sub: data.sub, refreshToken: data.refreshToken }, cookieDomain, data.ttl, refreshCookieName);
87
+ res.status(200).json(data).end();
88
+ }
89
+ async function finishRegister(req, res) {
90
+ const up = await authFetch(req, `${authServerUrl}/webAuthn/register/finish`, {
91
+ method: "POST",
92
+ body: req.body,
93
+ });
94
+ const data = (await up.json());
95
+ if (!up.ok)
96
+ return res.status(up.status).json(data);
97
+ setSessionCookie(res, { sub: data.sub, roles: data.roles }, cookieDomain, data.ttl, accesscookieName);
98
+ res.status(204).end();
99
+ }
100
+ async function logout(req, res) {
101
+ const sid = req.cookies[accesscookieName];
102
+ if (sid)
103
+ await authFetch(req, `${authServerUrl}/logout`, {
104
+ method: "POST",
105
+ body: { sid },
106
+ });
107
+ clearAllCookies(res, cookieDomain, accesscookieName, registrationCookieName, refreshCookieName);
108
+ res.status(204).end();
109
+ }
110
+ async function me(req, res) {
111
+ const up = await authFetch(req, `${authServerUrl}/users/me`, {
112
+ method: "GET",
113
+ });
114
+ const data = (await up.json());
115
+ console.log(data.token);
116
+ const verified = await verifySignedAuthResponse(data.token, authServerUrl);
117
+ if (!verified) {
118
+ throw new Error("Invalid signed response from Auth Server");
119
+ }
120
+ if (verified.sub !== data.sub) {
121
+ throw new Error("Signature mismatch with data payload");
122
+ }
123
+ clearSessionCookie(res, cookieDomain, preAuthCookieName);
124
+ if (!data.user)
125
+ return res.status(401).json({ error: "unauthenticated" });
126
+ res.json({ user: data.user });
127
+ }
128
+ }
@@ -0,0 +1,6 @@
1
+ import { createSeamlessAuthServer } from "./createServer";
2
+ export { requireAuth } from "./middleware/requireAuth";
3
+ export { requireRole } from "./middleware/requireRole";
4
+ export { getSeamlessUser } from "./internal/getSeamlessUser";
5
+ export type { SeamlessAuthServerOptions } from "./types";
6
+ export default createSeamlessAuthServer;
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ import { createSeamlessAuthServer } from './createServer.js';
2
+ export { requireAuth } from './middleware/requireAuth.js';
3
+ export { requireRole } from './middleware/requireRole.js';
4
+ export { getSeamlessUser } from './internal/getSeamlessUser.js';
5
+ export default createSeamlessAuthServer;
@@ -0,0 +1,8 @@
1
+ import { CookieRequest } from "../middleware/ensureCookies";
2
+ export interface AuthFetchOptions {
3
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
4
+ body?: any;
5
+ cookies?: string[];
6
+ headers?: Record<string, string>;
7
+ }
8
+ export declare function authFetch(req: CookieRequest, url: string, { method, body, cookies, headers }?: AuthFetchOptions): Promise<import("node-fetch").Response>;
@@ -0,0 +1,31 @@
1
+ import fetch from "node-fetch";
2
+ import jwt from "jsonwebtoken";
3
+ const privateKey = process.env.SERVICE_JWT_KEY;
4
+ export async function authFetch(req, url, { method = "POST", body, cookies, headers = {} } = {}) {
5
+ const token = privateKey
6
+ ? jwt.sign({ aud: "auth-internal", iss: "portal-api", sub: req.cookiePayload?.sub, role: req.cookiePayload?.roles }, privateKey, {
7
+ expiresIn: "60s",
8
+ keyid: "service-main",
9
+ })
10
+ : undefined;
11
+ if (!token) {
12
+ throw new Error("Cannot sign JWT for communications with Seamless Auth Server. Did you set your SERVICE_JWT_KEY?");
13
+ }
14
+ const finalHeaders = {
15
+ ...(method !== "GET" && { "Content-Type": "application/json" }),
16
+ ...(cookies ? { Cookie: cookies.join("; ") } : {}),
17
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
18
+ ...headers,
19
+ };
20
+ let finalUrl = url;
21
+ if (method === "GET" && body && typeof body === "object") {
22
+ const qs = new URLSearchParams(body).toString();
23
+ finalUrl += url.includes("?") ? `&${qs}` : `?${qs}`;
24
+ }
25
+ const res = await fetch(finalUrl, {
26
+ method,
27
+ headers: finalHeaders,
28
+ ...(method !== "GET" && body ? { body: JSON.stringify(body) } : {}),
29
+ });
30
+ return res;
31
+ }
@@ -0,0 +1,9 @@
1
+ import { Response } from "express";
2
+ export interface CookiePayload {
3
+ sub: string;
4
+ refreshToken?: string;
5
+ roles?: string[];
6
+ }
7
+ export declare function setSessionCookie(res: Response, payload: CookiePayload, domain?: string, ttlSeconds?: number, name?: string): void;
8
+ export declare function clearSessionCookie(res: Response, domain: string, name?: string): void;
9
+ export declare function clearAllCookies(res: Response, domain: string, accesscookieName: string, registrationCookieName: string, refreshCookieName: string): void;
@@ -0,0 +1,27 @@
1
+ import jwt from "jsonwebtoken";
2
+ const COOKIE_SECRET = process.env.SEAMLESS_COOKIE_SIGNING_KEY;
3
+ if (!COOKIE_SECRET) {
4
+ console.warn("[SeamlessAuth] Missing SEAMLESS_COOKIE_SIGNING_KEY env var!");
5
+ }
6
+ export function setSessionCookie(res, payload, domain, ttlSeconds = 300, name = "sa_session") {
7
+ const token = jwt.sign(payload, COOKIE_SECRET, {
8
+ algorithm: "HS256",
9
+ expiresIn: ttlSeconds,
10
+ });
11
+ res.cookie(name, token, {
12
+ httpOnly: true,
13
+ secure: true,
14
+ sameSite: "lax",
15
+ path: "/",
16
+ domain,
17
+ maxAge: ttlSeconds * 1000,
18
+ });
19
+ }
20
+ export function clearSessionCookie(res, domain, name = "sa_session") {
21
+ res.clearCookie(name, { domain, path: "/" });
22
+ }
23
+ export function clearAllCookies(res, domain, accesscookieName, registrationCookieName, refreshCookieName) {
24
+ res.clearCookie(accesscookieName, { domain, path: "/" });
25
+ res.clearCookie(registrationCookieName, { domain, path: "/" });
26
+ res.clearCookie(refreshCookieName, { domain, path: "/" });
27
+ }
@@ -0,0 +1,10 @@
1
+ import type { Request } from "express";
2
+ /**
3
+ * Retrieves the Seamless Auth user information by calling the auth server's introspection endpoint.
4
+ * Requires the sa_session (or custom) cookie to be present on the request.
5
+ *
6
+ * @param req Express request object
7
+ * @param authServerUrl Base URL of the client's auth server
8
+ * @returns The user data object if valid, or null if invalid/unauthenticated
9
+ */
10
+ export declare function getSeamlessUser<T = any>(req: Request, authServerUrl: string): Promise<T | null>;
@@ -0,0 +1,34 @@
1
+ import { authFetch } from "./authFetch.js";
2
+ import { verifySignedAuthResponse } from "./verifySignedAuthResponse.js";
3
+ /**
4
+ * Retrieves the Seamless Auth user information by calling the auth server's introspection endpoint.
5
+ * Requires the sa_session (or custom) cookie to be present on the request.
6
+ *
7
+ * @param req Express request object
8
+ * @param authServerUrl Base URL of the client's auth server
9
+ * @returns The user data object if valid, or null if invalid/unauthenticated
10
+ */
11
+ export async function getSeamlessUser(req, authServerUrl) {
12
+ try {
13
+ const response = await authFetch(req, `${authServerUrl}/users/me`, {
14
+ method: "GET",
15
+ });
16
+ if (!response.ok) {
17
+ console.warn(`[SeamlessAuth] Auth server responded ${response.status}`);
18
+ return null;
19
+ }
20
+ const data = (await response.json());
21
+ const verified = await verifySignedAuthResponse(data.token, authServerUrl);
22
+ if (!verified) {
23
+ throw new Error("Invalid signed response from Auth Server");
24
+ }
25
+ if (verified.sub !== data.sub) {
26
+ throw new Error("Signature mismatch with data payload");
27
+ }
28
+ return data.user;
29
+ }
30
+ catch (err) {
31
+ console.error("[SeamlessAuth] getSeamlessUser failed:", err);
32
+ return null;
33
+ }
34
+ }
@@ -0,0 +1 @@
1
+ export declare function verifyCookieJwt<T = any>(token: string): T | null;
@@ -0,0 +1,13 @@
1
+ import jwt from "jsonwebtoken";
2
+ const COOKIE_SECRET = process.env.SEAMLESS_COOKIE_SIGNING_KEY;
3
+ export function verifyCookieJwt(token) {
4
+ try {
5
+ return jwt.verify(token, COOKIE_SECRET, {
6
+ algorithms: ["HS256"],
7
+ });
8
+ }
9
+ catch (err) {
10
+ console.error("[SeamlessAuth] Cookie JWT verification failed:", err);
11
+ return null;
12
+ }
13
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Verifies a signed response JWT from a Seamless Auth server.
3
+ * Uses the Auth Server's JWKS endpoint to dynamically fetch public keys.
4
+ */
5
+ export declare function verifySignedAuthResponse<T = any>(token: string, authServerUrl: string): Promise<T | null>;
@@ -0,0 +1,22 @@
1
+ import { createRemoteJWKSet, jwtVerify } from "jose";
2
+ /**
3
+ * Verifies a signed response JWT from a Seamless Auth server.
4
+ * Uses the Auth Server's JWKS endpoint to dynamically fetch public keys.
5
+ */
6
+ export async function verifySignedAuthResponse(token, authServerUrl) {
7
+ try {
8
+ // Construct JWKS URL from auth server
9
+ const jwksUrl = new URL("/.well-known/jwks.json", authServerUrl).toString();
10
+ // Create a remote JWKS verifier (auto-caches)
11
+ const JWKS = createRemoteJWKSet(new URL(jwksUrl));
12
+ // Verify signature and algorithm
13
+ const { payload } = await jwtVerify(token, JWKS, {
14
+ algorithms: ["RS256"],
15
+ });
16
+ return payload;
17
+ }
18
+ catch (err) {
19
+ console.error("[SeamlessAuth] Failed to verify signed auth response:", err);
20
+ return null;
21
+ }
22
+ }
@@ -0,0 +1,7 @@
1
+ import { NextFunction, Request, Response } from "express";
2
+ import { SeamlessAuthServerOptions } from "../types";
3
+ import { JwtPayload } from "jsonwebtoken";
4
+ export interface CookieRequest extends Request {
5
+ cookiePayload?: JwtPayload;
6
+ }
7
+ export declare function createEnsureCookiesMiddleware(opts: SeamlessAuthServerOptions): (req: CookieRequest, res: Response, next: NextFunction) => void | Response<any, Record<string, any>>;
@@ -0,0 +1,32 @@
1
+ import { verifyCookieJwt } from "../internal/verifyCookieJwt.js";
2
+ export function createEnsureCookiesMiddleware(opts) {
3
+ const COOKIE_REQUIREMENTS = {
4
+ "/webAuthn/login/finish": { name: opts.preAuthCookieName, required: true },
5
+ "/webAuthn/login/start": { name: opts.preAuthCookieName, required: true },
6
+ "/webAuthn/register/start": { name: opts.registrationCookieName, required: true },
7
+ "/webAuthn/register/finish": { name: opts.registrationCookieName, required: true },
8
+ "/otp/verify-email-otp": { name: opts.registrationCookieName, required: true },
9
+ "/otp/verify-phone-otp": { name: opts.registrationCookieName, required: true },
10
+ "/logout": { name: opts.accesscookieName, required: true },
11
+ "/users/me": { name: opts.accesscookieName, required: true },
12
+ };
13
+ return function ensureCookies(req, res, next) {
14
+ const match = Object.entries(COOKIE_REQUIREMENTS).find(([path]) => req.path.startsWith(path));
15
+ if (!match)
16
+ return next();
17
+ const [, { name, required }] = match;
18
+ const cookieValue = req.cookies?.[name];
19
+ if (required && !cookieValue) {
20
+ return res.status(400).json({
21
+ error: `Missing required cookie "${name}" for route ${req.path}`,
22
+ hint: "Did you forget to call /auth/login/start first?",
23
+ });
24
+ }
25
+ const payload = verifyCookieJwt(cookieValue);
26
+ if (!payload) {
27
+ return res.status(401).json({ error: `Invalid or expired ${name} cookie` });
28
+ }
29
+ req.cookiePayload = payload;
30
+ next();
31
+ };
32
+ }
@@ -0,0 +1,8 @@
1
+ import { Request, Response, NextFunction } from "express";
2
+ /**
3
+ * Express middleware that verifies a Seamless Auth access cookie.
4
+ * - Reads and verifies signed cookie JWT
5
+ * - Attaches decoded payload to req.user
6
+ * - Returns 401 if missing/invalid/expired
7
+ */
8
+ export declare function requireAuth(cookieName?: string): (req: Request, res: Response, next: NextFunction) => void;
@@ -0,0 +1,33 @@
1
+ import jwt from "jsonwebtoken";
2
+ const COOKIE_SECRET = process.env.SEAMLESS_COOKIE_SIGNING_KEY;
3
+ if (!COOKIE_SECRET) {
4
+ console.warn("[SeamlessAuth] SEAMLESS_COOKIE_SIGNING_KEY missing — requireAuth will always fail.");
5
+ }
6
+ /**
7
+ * Express middleware that verifies a Seamless Auth access cookie.
8
+ * - Reads and verifies signed cookie JWT
9
+ * - Attaches decoded payload to req.user
10
+ * - Returns 401 if missing/invalid/expired
11
+ */
12
+ export function requireAuth(cookieName = "seamless-auth-access") {
13
+ return (req, res, next) => {
14
+ try {
15
+ const token = req.cookies?.[cookieName];
16
+ if (!token) {
17
+ res.status(401).json({ error: "Missing access cookie" });
18
+ return;
19
+ }
20
+ const payload = jwt.verify(token, COOKIE_SECRET, {
21
+ algorithms: ["HS256"],
22
+ });
23
+ // Attach decoded JWT claims to request for downstream handlers
24
+ req.user = payload;
25
+ next();
26
+ }
27
+ catch (err) {
28
+ console.error("[SeamlessAuth] requireAuth error:", err.message);
29
+ res.status(401).json({ error: "Invalid or expired access cookie" });
30
+ return;
31
+ }
32
+ };
33
+ }
@@ -0,0 +1,8 @@
1
+ import { RequestHandler } from "express";
2
+ /**
3
+ * Express middleware to enforce a required role from Seamless Auth cookie JWT.
4
+ *
5
+ * @param role Role name to require (e.g. 'admin')
6
+ * @param cookieName Cookie name containing JWT (default: 'sa_session')
7
+ */
8
+ export declare function requireRole(role: string, cookieName?: string): RequestHandler;
@@ -0,0 +1,36 @@
1
+ import jwt from "jsonwebtoken";
2
+ const COOKIE_SECRET = process.env.SEAMLESS_COOKIE_SIGNING_KEY;
3
+ if (!COOKIE_SECRET) {
4
+ console.warn("[PortalAPI] Missing SEAMLESS_COOKIE_SIGNING_KEY — role checks will fail.");
5
+ }
6
+ /**
7
+ * Express middleware to enforce a required role from Seamless Auth cookie JWT.
8
+ *
9
+ * @param role Role name to require (e.g. 'admin')
10
+ * @param cookieName Cookie name containing JWT (default: 'sa_session')
11
+ */
12
+ export function requireRole(role, cookieName = "seamless-auth-access") {
13
+ return (req, res, next) => {
14
+ try {
15
+ const token = req.cookies?.[cookieName];
16
+ if (!token) {
17
+ res.status(401).json({ error: "Missing access cookie" });
18
+ return;
19
+ }
20
+ // Verify JWT signature
21
+ const payload = jwt.verify(token, COOKIE_SECRET, {
22
+ algorithms: ["HS256"],
23
+ });
24
+ // Check role membership
25
+ if (!payload.roles?.includes(role)) {
26
+ res.status(403).json({ error: `Forbidden: ${role} role required` });
27
+ return;
28
+ }
29
+ next();
30
+ }
31
+ catch (err) {
32
+ console.error(`[PortalAPI] requireRole(${role}) failed:`, err.message);
33
+ res.status(401).json({ error: "Invalid or expired access cookie" });
34
+ }
35
+ };
36
+ }
@@ -0,0 +1,8 @@
1
+ export interface SeamlessAuthServerOptions {
2
+ authServerUrl: string;
3
+ cookieDomain?: string;
4
+ accesscookieName?: string;
5
+ registrationCookieName?: string;
6
+ refreshCookieName?: string;
7
+ preAuthCookieName?: string;
8
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@seamless-auth/express",
3
+ "version": "0.0.0",
4
+ "description": "Express adapter for Seamless Auth passwordless authentication",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "author": "Fells Code LLC",
10
+ "scripts": {
11
+ "build": "tsc -p tsconfig.json && node build.mjs",
12
+ "dev": "tsc --watch"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/fells-code/seamless-auth-server"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "peerDependencies": {
22
+ "@types/express": ">=5.0.0",
23
+ "express": ">=5.1.0"
24
+ },
25
+ "dependencies": {
26
+ "cookie-parser": "^1.4.6",
27
+ "jose": "^6.1.1",
28
+ "jsonwebtoken": "^9.0.2",
29
+ "node-fetch": "^3.3.2",
30
+ "version-packages": "changeset version",
31
+ "release": "changeset publish"
32
+ },
33
+ "devDependencies": {
34
+ "@changesets/cli": "^2.29.7",
35
+ "@types/cookie-parser": "^1.4.10",
36
+ "@types/jsonwebtoken": "^9.0.10",
37
+ "typescript": "^5.5.0"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }