diff --git a/.github/workflows/beta-release-pipelie.yml b/.github/workflows/beta-release-pipelie.yml deleted file mode 100644 index 5c42a60..0000000 --- a/.github/workflows/beta-release-pipelie.yml +++ /dev/null @@ -1,158 +0,0 @@ -name: Beta Release Pipeline - -on: - push: - branches: - - dev - -permissions: - contents: write - id-token: write - -jobs: - test: - name: Run Tests - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Install Core dependencies - working-directory: packages/core - run: npm ci - - - name: Install Express dependencies - working-directory: packages/express - run: npm ci - - - name: Test Core - working-directory: packages/core - run: npm test - - - name: Test Express - working-directory: packages/express - run: npm test - - bump-beta-versions: - name: Bump Beta Versions - runs-on: ubuntu-latest - needs: test - if: "!contains(github.event.head_commit.message, 'chore: bump beta versions')" - - outputs: - core_changed: ${{ steps.changes.outputs.core }} - express_changed: ${{ steps.changes.outputs.express }} - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Detect package changes - id: changes - run: | - CORE=false - EXPRESS=false - - if git diff --name-only HEAD~1 HEAD | grep '^packages/core/'; then - CORE=true - fi - - if git diff --name-only HEAD~1 HEAD | grep '^packages/express/'; then - EXPRESS=true - fi - - echo "core=$CORE" >> $GITHUB_OUTPUT - echo "express=$EXPRESS" >> $GITHUB_OUTPUT - - - uses: actions/setup-node@v4 - if: steps.changes.outputs.core == 'true' || steps.changes.outputs.express == 'true' - with: - node-version: 20 - - - name: Bump core beta version - if: steps.changes.outputs.core == 'true' - working-directory: packages/core - run: npm version prerelease --preid=beta --no-git-tag-version - - - name: Bump express beta version - if: steps.changes.outputs.express == 'true' - working-directory: packages/express - run: npm version prerelease --preid=beta --no-git-tag-version - - - name: Commit version bumps - if: steps.changes.outputs.core == 'true' || steps.changes.outputs.express == 'true' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add packages/*/package.json - git commit -m "chore: bump beta versions" - git push origin dev - - publish-core-beta: - name: Publish Core Beta - runs-on: ubuntu-latest - needs: bump-beta-versions - if: needs.bump-beta-versions.outputs.core_changed == 'true' - - steps: - - uses: actions/checkout@v4 - with: - ref: dev - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - registry-url: https://registry.npmjs.org/ - - - name: Install dependencies - working-directory: packages/core - run: npm ci - - - name: Build - working-directory: packages/core - run: npm run build - - - name: Publish beta - working-directory: packages/core - run: npm publish --tag beta --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - publish-express-beta: - name: Publish Express Beta - runs-on: ubuntu-latest - needs: bump-beta-versions - if: needs.bump-beta-versions.outputs.express_changed == 'true' - - steps: - - uses: actions/checkout@v4 - with: - ref: dev - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - registry-url: https://registry.npmjs.org/ - - - name: Install dependencies - working-directory: packages/express - run: npm ci - - - name: Build - working-directory: packages/express - run: npm run build - - - name: Publish beta - working-directory: packages/express - run: npm publish --tag beta --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json index 7d430b8..e14b820 100644 --- a/packages/core/package-lock.json +++ b/packages/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@seamless-auth/core", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/core", - "version": "0.1.1", + "version": "0.2.0", "license": "AGPL-3.0-only", "dependencies": { "jose": "^6.1.3", diff --git a/packages/core/package.json b/packages/core/package.json index 2641582..70d443a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@seamless-auth/core", - "version": "0.1.2-beta.0", + "version": "0.2.0", "description": "Framework-agnostic core authentication logic for SeamlessAuth", "license": "AGPL-3.0-only", "author": "Fells Code, LLC", diff --git a/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts b/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts new file mode 100644 index 0000000..ee16a5a --- /dev/null +++ b/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts @@ -0,0 +1,102 @@ +import { authFetch } from "../authFetch.js"; +import type { CookiePayload } from "../ensureCookies.js"; +import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js"; + +export interface PollMagicLinkConfirmationInput { + authorization?: string; +} + +export interface PollMagicLinkConfirmationOptions { + authServerUrl: string; + cookieDomain?: string; + accessCookieName: string; + refreshCookieName: string; +} + +export interface PollMagicLinkConfirmationResult { + status: number; + body?: unknown; + error?: unknown; + setCookies?: { + name: string; + value: CookiePayload; + ttl: number; + domain?: string; + }[]; +} + +export async function pollMagicLinkConfirmationHandler( + input: PollMagicLinkConfirmationInput, + opts: PollMagicLinkConfirmationOptions, +): Promise { + const up = await authFetch(`${opts.authServerUrl}/magic-link/check`, { + method: "GET", + authorization: input.authorization, + }); + + // 👇 Pending state (important for polling UX) + if (up.status === 204) { + return { + status: 204, + body: { message: "Not verified." }, + }; + } + + const data = await up.json(); + + if (!up.ok) { + return { + status: up.status, + error: data, + }; + } + + // 👇 Web mode: auth server already handled cookies + if (!data?.token || !data?.refreshToken || !data?.sub) { + return { + status: up.status, + body: data, + }; + } + + // 🔐 Verify signed response (same as WebAuthn flow) + 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"); + } + + return { + status: 200, + body: data, + setCookies: [ + { + name: opts.accessCookieName, + value: { + sub: data.sub, + roles: data.roles, + email: data.email, + phone: data.phone, + }, + ttl: data.ttl, + domain: opts.cookieDomain, + }, + { + name: opts.refreshCookieName, + value: { + sub: data.sub, + refreshToken: data.refreshToken, + }, + ttl: data.refreshTtl, + domain: opts.cookieDomain, + }, + ], + }; +} diff --git a/packages/core/src/handlers/requestMagicLinkHandler.ts b/packages/core/src/handlers/requestMagicLinkHandler.ts new file mode 100644 index 0000000..1bc8abb --- /dev/null +++ b/packages/core/src/handlers/requestMagicLinkHandler.ts @@ -0,0 +1,39 @@ +import { authFetch } from "../authFetch.js"; + +export interface RequestMagicLinkInput { + authorization?: string; +} + +export interface RequestMagicLinkOptions { + authServerUrl: string; +} + +export interface RequestMagicLinkResult { + status: number; + body?: unknown; + error?: unknown; +} + +export async function requestMagicLinkHandler( + input: RequestMagicLinkInput, + opts: RequestMagicLinkOptions, +): Promise { + const up = await authFetch(`${opts.authServerUrl}/magic-link`, { + method: "GET", + authorization: input.authorization, + }); + + const data = await up.json(); + + if (!up.ok) { + return { + status: up.status, + error: data, + }; + } + + return { + status: up.status, + body: data, + }; +} diff --git a/packages/core/src/handlers/verifyMagicLinkHandler.ts b/packages/core/src/handlers/verifyMagicLinkHandler.ts new file mode 100644 index 0000000..0708df7 --- /dev/null +++ b/packages/core/src/handlers/verifyMagicLinkHandler.ts @@ -0,0 +1,41 @@ +import { authFetch } from "../authFetch.js"; + +export interface VerifyMagicLinkInput { + token: string; +} + +export interface VerifyMagicLinkOptions { + authServerUrl: string; +} + +export interface VerifyMagicLinkResult { + status: number; + body?: unknown; + error?: unknown; +} + +export async function verifyMagicLinkHandler( + input: VerifyMagicLinkInput, + opts: VerifyMagicLinkOptions, +): Promise { + const up = await authFetch( + `${opts.authServerUrl}/magic-link/verify/${input.token}`, + { + method: "GET", + }, + ); + + const data = await up.json(); + + if (!up.ok) { + return { + status: up.status, + error: data, + }; + } + + return { + status: up.status, + body: data, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c6bf0db..2e64ec2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,3 +11,6 @@ export * from "./handlers/register.js"; export * from "./handlers/finishRegister.js"; export * from "./handlers/logout.js"; export * from "./handlers/me.js"; +export * from "./handlers/verifyMagicLinkHandler.js"; +export * from "./handlers/requestMagicLinkHandler.js"; +export * from "./handlers/pollMagicLinkConfirmationHandler.js"; diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index c855384..e9b6cda 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -9,6 +9,7 @@ import { register } from "./handlers/register"; import { finishRegister } from "./handlers/finishRegister"; import { me } from "./handlers/me"; import { logout } from "./handlers/logout"; +import { pollMagicLinkConfirmation } from "./handlers/pollMagicLinkConfirmation"; import { authFetch, @@ -235,6 +236,19 @@ export function createSeamlessAuthServer( "/users/credentials", proxyWithIdentity("users/credentials", "access"), ); + r.get("/magic-link", proxyWithIdentity("magic-link", "preAuth", "GET")); + r.get("/magic-link/verify/:token", async (req, res) => { + const upstream = await authFetch( + `${resolvedOpts.authServerUrl}/magic-link/verify/${req.params.token}`, + { method: "GET" }, + ); + + const data = await upstream.json(); + res.status(upstream.status).json(data); + }); + r.get("/magic-link/check", (req, res) => + pollMagicLinkConfirmation(req, res, resolvedOpts), + ); return r; } diff --git a/packages/express/src/handlers/pollMagicLinkConfirmation.ts b/packages/express/src/handlers/pollMagicLinkConfirmation.ts new file mode 100644 index 0000000..79bc96c --- /dev/null +++ b/packages/express/src/handlers/pollMagicLinkConfirmation.ts @@ -0,0 +1,58 @@ +import { Request, Response } from "express"; +import { pollMagicLinkConfirmationHandler } from "@seamless-auth/core/handlers/magicLink/pollMagicLinkConfirmation"; +import { setSessionCookie } from "../internal/cookie"; +import { buildServiceAuthorization } from "../internal/buildAuthorization"; +import { SeamlessAuthServerOptions } from "../createServer"; + +export async function pollMagicLinkConfirmation( + req: Request & { cookiePayload?: any }, + res: Response, + opts: SeamlessAuthServerOptions, +) { + const cookieSigner = { + secret: opts.cookieSecret, + secure: process.env.NODE_ENV === "production", + sameSite: + process.env.NODE_ENV === "production" + ? "none" + : ("lax" as "none" | "lax"), + }; + + const authorization = buildServiceAuthorization(req, opts); + + const result = await pollMagicLinkConfirmationHandler( + { authorization }, + { + authServerUrl: opts.authServerUrl, + cookieDomain: opts.cookieDomain, + accessCookieName: opts.accessCookieName!, + refreshCookieName: opts.refreshCookieName!, + }, + ); + + if (!cookieSigner.secret) { + throw new Error("Missing COOKIE_SIGNING_KEY"); + } + + // 🍪 Set cookies if returned + if (result.setCookies) { + for (const c of result.setCookies) { + setSessionCookie( + res, + { + name: c.name, + payload: c.value, + domain: c.domain, + ttlSeconds: c.ttl, + }, + cookieSigner, + ); + } + } + + if (result.error) { + return res.status(result.status).json(result.error); + } + + res.status(result.status).json(result.body).end(); +} diff --git a/packages/express/src/middleware/requireAuth.ts b/packages/express/src/middleware/requireAuth.ts index 3f3cb5e..710670e 100644 --- a/packages/express/src/middleware/requireAuth.ts +++ b/packages/express/src/middleware/requireAuth.ts @@ -81,8 +81,12 @@ export function requireAuth(opts: RequireAuthOptions) { const token = req.cookies?.[cookieName]; if (!token) { + console.error( + "[SEAMLESS-AUTH-EXPRESS] - (requireAuth) - Missing expected cookie. Ensure you are using `cookieParser` in your express server", + cookieName, + ); res.status(401).json({ - error: "Authentication required", + error: "Failed to find authentication token required", }); return; }