Skip to content

CorePass/authjs-corepass-provider

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

37 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

authjs-corepass-provider

CorePass provider + server helpers for Auth.js (@auth/core) implementing the pending-by-default registration flow:

  • CorePass first checks HEAD /passkey/data: 200 = enrichment available (pending mode), 404 = enrichment not available (e.g. allowImmediateFinalize enabled).
  • If enrichment available (200): browser does WebAuthn attestation via POST /webauthn/start and POST /webauthn/finish → server stores a pending registration → CorePass app finalizes by calling POST /passkey/data with an Ed448-signed payload.
  • If enrichment not available (404): browser completes attestation and finalizes in one go via POST /webauthn/finish with coreId (and optional data); no enrich step.

What you get

  • Provider: CorePass() (wraps Auth.js WebAuthn with passkey-friendly defaults)
  • Server helpers: createCorePassServer() exposing handlers:
    • startRegistration(req)
    • finishRegistration(req)
    • enrichRegistration(req) (your /passkey/data)
    • checkEnrichment() (HEAD /passkey/data: 200 when enrichment available, 404 when allowImmediateFinalize is enabled)
  • DB extension schema: db/corepass-schema.sql

Flows

Registration flow (pending-by-default)

sequenceDiagram
  autonumber
  actor B as Browser
  participant S as Your backend
  participant KV as Challenge store
  participant DB as CorePass store
  actor A as CorePass app

  A->>S: HEAD /passkey/data
  S-->>A: 200 (enrichment available) or 404 (use immediate finalize in finish, not shown)
  Note over A: If 200, use enrich flow below

  B->>S: POST /webauthn/start { email? }
  Note over B,S: Pending TTL default is 10 minutes (pendingTtlSeconds=600)
  S->>KV: put reg:sid {challenge,email} ttl
  S-->>B: 200 CreationOptions + Set-Cookie corepass.sid
  B->>B: navigator.credentials.create()
  B->>S: POST /webauthn/finish { attestation, email? }
  S->>KV: get+delete reg:sid
  S->>S: verifyRegistrationResponse()
  S->>DB: createPendingRegistration(credentialId, publicKey, counter, aaguid, email?)
  S-->>B: 200 { pending:true, enrichToken, credentialId }

  A->>S: POST /passkey/data {coreId, credentialId, timestamp, userData} + X-Signature (Ed448)
  Note over A,S: Only when enrichment available (HEAD returned 200)
  S->>S: validateCoreIdMainnet + timestamp window
  S->>S: verify Ed448 signature over canonical JSON
  S->>DB: load+delete pending by credentialId
  S->>S: create/link Auth.js user+account+authenticator
  S->>DB: upsert CorePass identity/profile (provided_till, flags)
  S->>S: (optional) POST registration webhook { coreId, refId? } (registrationWebhookRetries, default 3)
  Note over S: If registrationWebhookSecret is set, include HMAC headers:\nX-Webhook-Timestamp + X-Webhook-Signature
  S-->>A: 200 ok
Loading

Login flow (standard Auth.js WebAuthn authenticate)

CorePass login is normal WebAuthn: it uses the Auth.js WebAuthn callback path (action=authenticate), and resolves the user by stored authenticators.

sequenceDiagram
  autonumber
  actor B as Browser
  participant Auth as Auth.js (@auth/core)
  participant DB as Adapter DB

  B->>Auth: GET /auth/webauthn-options?action=authenticate (provider=corepass)
  Auth->>DB: listAuthenticatorsByUserId (optional) / challenge cookie
  Auth-->>B: 200 RequestOptions + challenge cookie
  B->>B: navigator.credentials.get()
  B->>Auth: POST /auth/callback/corepass { action:"authenticate", data }
  Auth->>DB: getAuthenticator(credentialId) + verifyAuthenticationResponse()
  Auth->>DB: updateAuthenticatorCounter()
  Auth-->>B: session established
  Note over Auth: (optional) POST login webhook { coreId, refId? } (loginWebhookRetries, default 3)
Loading

Install

npm install authjs-corepass-provider

You also need:

  • @auth/core (peer dependency)
  • @simplewebauthn/browser in your frontend

Auth.js configuration

import { Auth } from "@auth/core"
import CorePass from "authjs-corepass-provider/provider"

export const auth = (req: Request) =>
  Auth(req, {
    providers: [CorePass()],
    adapter: /* your Auth.js adapter */,
  })

CorePass endpoints

You mount these where you want in your app (framework-specific). The handlers are plain Web API Request -> Response.

import { createCorePassServer } from "authjs-corepass-provider"

const corepass = createCorePassServer({
  adapter: /* Auth.js adapter (must implement WebAuthn + user methods) */,
  // store must implement CorePassStore:
  // - pending registrations (default flow)
  // - coreId <-> userId identity mapping
  // - profile metadata (o18y/o21y/kyc/provided_till)
  //
  // Built-ins:
  // - d1CorePassStore(db) for Cloudflare D1
  // - postgresCorePassStore(pg) for Postgres (node-postgres, etc)
  // - supabaseCorePassStore(supabase) for Supabase client
  store: /* CorePassStore implementation */,
  challengeStore: /* CorePassChallengeStore implementation (KV/Redis/etc) */,
  rpID: "example.com",
  rpName: "Example",
  expectedOrigin: "https://example.com",

  // default: pending registrations are required
  allowImmediateFinalize: false,
})

// Optional: login webhook (call from Auth.js events.signIn)
// events: {
//   async signIn({ user, account }) {
//     if (account?.provider === "corepass" && account?.type === "webauthn" && user?.id) {
//       await corepass.postLoginWebhook({ userId: user.id })
//     }
//   }
// }
//
// Optional: logout webhook (call from Auth.js events.signOut)
// events: {
//   async signOut({ session }) {
//     // You must be able to map the logout event to a userId.
//     // How you obtain userId depends on your Auth.js setup/session strategy.
//     // If you have it:
//     // await corepass.postLogoutWebhook({ userId })
//   }
// }

export async function POST(req: Request) {
  const url = new URL(req.url)
  if (url.pathname === "/webauthn/start") return corepass.startRegistration(req)
  if (url.pathname === "/webauthn/finish") return corepass.finishRegistration(req)
  if (url.pathname === "/passkey/data") return corepass.enrichRegistration(req)
  return new Response("Not found", { status: 404 })
}

export async function HEAD(req: Request) {
  const url = new URL(req.url)
  if (url.pathname === "/passkey/data") return corepass.checkEnrichment()
  return new Response(null, { status: 404 })
}

“Unified” server factory helpers (same DB client)

If you want to avoid manually wiring store: d1CorePassStore(...) etc, you can use the factories:

  • createCorePassServerD1({ db, ... })
  • createCorePassServerPostgres({ pg, ... })
  • createCorePassServerSupabase({ supabase, ... })
  • createCorePassServerCloudflareD1Kv({ db, kv, ... })
  • createCorePassServerPostgresRedis({ pg, redis, ... })
  • createCorePassServerSupabaseUpstash({ supabase, redis, ... })
  • createCorePassServerSupabaseVercelKv({ supabase, kv, ... })

This does not create an Auth.js adapter for you (adapters are separate packages), but it ensures the CorePass store uses the same DB client you pass in.

challengeStore (what it is, and what it supports)

challengeStore is not an Auth.js provider and it is not tied to WebAuthn/Passkey provider IDs. It’s a minimal storage interface used by this package’s custom endpoints to persist the WebAuthn challenge between:

  • POST /webauthn/start (generate challenge)
  • POST /webauthn/finish (verify attestation against expected challenge)

It must support TTL (seconds) and delete on use.

How to wire it to real systems

You create/access your KV/Redis client in your runtime and pass it into the helper:

  • Cloudflare Workers: use an env binding
  • Node/Next.js/etc: create a Redis client using URL/token from env vars

Example: in-memory (development only)

import { memoryChallengeStore } from "authjs-corepass-provider"

Example: Redis / Upstash

import { redisChallengeStore } from "authjs-corepass-provider"
import { Redis } from "ioredis"

const redis = new Redis(process.env.REDIS_URL!)

const challengeStore = redisChallengeStore({
    set: (key, value, { ex }) => redis.set(key, value, "EX", ex),
    get: (key) => redis.get(key),
    del: (key) => redis.del(key),
})

Example: Cloudflare KV

import { kvChallengeStore } from "authjs-corepass-provider"

// wrangler.jsonc:
// {
//     "kv_namespaces": [
//         { "binding": "COREPASS_KV", "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }
//     ]
// }
//
// Worker handler: env.COREPASS_KV is a KVNamespace binding.
//
// const challengeStore = kvChallengeStore(env.COREPASS_KV)

Example: Vercel KV (Redis)

import { vercelKvChallengeStore } from "authjs-corepass-provider"
import { kv } from "@vercel/kv"

// Vercel manages connection details via environment variables.
// See Vercel KV setup for the required env vars.

const challengeStore = vercelKvChallengeStore({
    set: (key, value, { ex }) => kv.set(key, value, { ex }),
    get: (key) => kv.get<string>(key),
    del: (key) => kv.del(key),
})

Example: Upstash Redis REST (@upstash/redis)

import { upstashRedisChallengeStore } from "authjs-corepass-provider"
import { Redis } from "@upstash/redis"

const redis = new Redis({
    url: process.env.UPSTASH_REDIS_REST_URL!,
    token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})

const challengeStore = upstashRedisChallengeStore({
    set: (key, value, { ex }) => redis.set(key, value, { ex }),
    get: (key) => redis.get<string>(key),
    del: (key) => redis.del(key),
})

Example: Cloudflare Durable Objects

Durable Objects are a good fit if you want low-latency ephemeral state close to your Worker.

import { durableObjectChallengeStore } from "authjs-corepass-provider"

// Your Durable Object must implement these routes:
// - POST /challenge/put { key, value, ttlSeconds }
// - GET  /challenge/get?key=...
// - POST /challenge/delete { key }
//
// Then:
// const challengeStore = durableObjectChallengeStore(env.COREPASS_DO.get(id))

Example: DynamoDB (AWS SDK v3)

If you already run on AWS and want a managed KV with TTL:

import { dynamoChallengeStore } from "authjs-corepass-provider"
import { DynamoDBClient } from "@aws-sdk/client-dynamodb"
import { DynamoDBDocumentClient, PutCommand, GetCommand, DeleteCommand } from "@aws-sdk/lib-dynamodb"

const TableName = process.env.COREPASS_CHALLENGE_TABLE!
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}))
const nowSec = () => Math.floor(Date.now() / 1000)

// This library doesn't hard-depend on AWS SDK; you provide a small adapter:
const challengeStore = dynamoChallengeStore({
    put: ({ key, value, expiresAt }) =>
        ddb.send(new PutCommand({ TableName, Item: { pk: key, value, expiresAt } })),
    get: async (key) => {
        const res = await ddb.send(new GetCommand({ TableName, Key: { pk: key } }))
        const item = res.Item as { value?: string; expiresAt?: number } | undefined
        if (!item?.value || typeof item.expiresAt !== "number") return null
        if (item.expiresAt < nowSec()) return null
        return { value: item.value, expiresAt: item.expiresAt }
    },
    delete: (key) => ddb.send(new DeleteCommand({ TableName, Key: { pk: key } })),
})

Example: SQL / D1

Use a table like:

CREATE TABLE IF NOT EXISTS corepass_challenges (
  key TEXT PRIMARY KEY,
  value TEXT NOT NULL,
  expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_corepass_challenges_expires_at ON corepass_challenges(expires_at);

Then implement:

import type { CorePassChallengeStore } from "authjs-corepass-provider"

export function sqlChallengeStore(db: {
  exec: (sql: string, params?: unknown[]) => Promise<unknown>
  get: (sql: string, params?: unknown[]) => Promise<{ value: string; expires_at: number } | null>
}): CorePassChallengeStore {
  const nowSec = () => Math.floor(Date.now() / 1000)

  return {
    async put(key, value, ttlSeconds) {
      const expiresAt = nowSec() + ttlSeconds
      await db.exec(
        "INSERT OR REPLACE INTO corepass_challenges (key, value, expires_at) VALUES (?1, ?2, ?3)",
        [key, value, expiresAt]
      )
    },
    async get(key) {
      const row = await db.get(
        "SELECT value, expires_at FROM corepass_challenges WHERE key = ?1",
        [key]
      )
      if (!row) return null
      if (row.expires_at < nowSec()) {
        await db.exec("DELETE FROM corepass_challenges WHERE key = ?1", [key])
        return null
      }
      return row.value
    },
    async delete(key) {
      await db.exec("DELETE FROM corepass_challenges WHERE key = ?1", [key])
    },
  }
}

Database

Apply your adapter’s default Auth.js schema, then apply:

  • db/corepass-schema.sql (SQLite/D1)
  • db/corepass-schema.postgres.sql (PostgreSQL/Supabase)

This adds:

  • corepass_pending_registrations
  • corepass_identities (CoreID → Auth.js userId mapping)
  • corepass_profiles (CorePass metadata like o18y, kyc, provided_till)

Options

  • allowedAaguids: defaults to CorePass AAGUID 636f7265-7061-7373-6964-656e74696679. Pass a string or an array of AAGUIDs. Set to false to allow any authenticator.
  • pubKeyCredAlgs: defaults to [-257, -7, -8] (RS256, ES256, Ed25519).
  • WebAuthn registration options (optional overrides; defaults are passkey-friendly and privacy-friendly):
    • attestationType: "none" (default), "indirect", or "direct".
    • authenticatorAttachment: "cross-platform" (default) or "platform".
    • residentKey: "preferred" (default), "required", or "discouraged".
    • userVerification: "required" (default), "preferred", or "discouraged".
    • registrationTimeout: milliseconds; default 60000 (60 seconds).
  • allowImmediateFinalize: if enabled, finishRegistration may finalize immediately if coreId is provided in the browser payload. This is disabled by default because it weakens the CoreID ownership guarantee (the default flow requires the Ed448-signed /passkey/data request). When enabled, HEAD /passkey/data (checkEnrichment) returns 404 (enrichment not available).
  • emailRequired: defaults to false (email can arrive later via /passkey/data). If no email is provided, the user is created with email undefined; when a real email is provided later it is updated.
  • requireO18y: defaults to false. If enabled, /passkey/data must include userData.o18y=true or finalization is rejected. Not enforced for immediate-finalize.
  • requireO21y: defaults to false. If enabled, /passkey/data must include userData.o21y=true or finalization is rejected. Not enforced for immediate-finalize.
  • requireKyc: defaults to false. If enabled, /passkey/data must include userData.kyc=true or finalization is rejected. Not enforced for immediate-finalize.
  • enableRefId: defaults to false. When enabled, the server generates and stores a refId (UUIDv4) for the CoreID identity and can include it in webhooks. When disabled, no refId is generated or stored.
  • Registration webhook options:
    • postRegistrationWebhooks: defaults to false.
    • registrationWebhookUrl: required if postRegistrationWebhooks: true.
    • registrationWebhookSecret: optional. If set, requests are HMAC-signed (SHA-256) using timestamp + "\\n" + body and include X-Webhook-Timestamp (unix seconds) and X-Webhook-Signature (sha256=<hex>).
    • registrationWebhookRetries: defaults to 3 (range 1-10). Retries happen on non-2xx responses or network errors.
  • Login webhook options:
    • postLoginWebhooks: defaults to false.
    • loginWebhookUrl: required if postLoginWebhooks: true.
    • loginWebhookSecret: optional. Same signing format/headers as registration.
    • loginWebhookRetries: defaults to 3 (range 1-10). Retries happen on non-2xx responses or network errors.
  • Logout webhook options:
    • postLogoutWebhooks: defaults to false.
    • logoutWebhookUrl: required if postLogoutWebhooks: true.
    • logoutWebhookSecret: optional. Same signing format/headers as registration.
    • logoutWebhookRetries: defaults to 3 (range 1-10). Retries happen on non-2xx responses or network errors.
  • pendingTtlSeconds: defaults to 600 (10 minutes). Pending registrations expire after this and are dropped.
  • timestampWindowMs: defaults to 600000 (10 minutes). Enrichment timestamp must be within this window.

Enrichment payload (/passkey/data)

The CorePass app sends:

  • Body: { coreId, credentialId, timestamp, userData }
  • Header: X-Signature (Ed448 signature)

Canonical payload + signature input

For signature verification, the server does not use the raw request body bytes. Instead it:

  • Canonicalizes JSON: recursively sorts object keys alphabetically and serializes with JSON.stringify(...) (so it is minified, no whitespace).
  • Builds signature input as:
signatureInput = "POST\n" + signaturePath + "\n" + canonicalJsonBody

Then it verifies X-Signature (Ed448) over UTF-8(signatureInput).

This means the CorePass signer must sign the same canonical JSON string (alphabetically ordered + minified) and the same signaturePath (defaults to /passkey/data, configurable via signaturePath).

Timestamp units

timestamp is required and must be a Unix timestamp in microseconds.

userData fields:

Field Type Example Notes
email string user@example.com Optional. If omitted, user email is left undefined; if provided later, Auth.js user email is updated.
o18y boolean (or 0/1) true Stored in corepass_profiles.o18y.
o21y boolean (or 0/1) false Stored in corepass_profiles.o21y.
kyc boolean (or 0/1) true Stored in corepass_profiles.kyc.
kycDoc string PASSPORT Stored in corepass_profiles.kyc_doc.
dataExp number 43829 Minutes. Converted to provided_till.

refId is not part of CorePass /passkey/data. If you need an external correlation id, enable enableRefId and deliver it via your webhooks.

Email when not provided

Email is not required unless you set emailRequired: true. When no email is supplied at finalization, the user is created with email undefined. If a real email is provided later (e.g. in userData.email), the user is updated via adapter.updateUser. Your adapter and database must allow missing/null email.

provided_till calculation

provided_till is stored as a Unix timestamp in seconds:

provided_till = floor(now_sec) + dataExpMinutes * 60

Notes on Auth.js internals

Auth.js’ built-in WebAuthn flow normally creates the user/account/authenticator during the WebAuthn callback. CorePass intentionally delays this until enrichment, so it uses custom endpoints instead of Auth.js’ built-in “register” callback path.

Upstream references

  • Auth.js contributing guide: https://raw.githubusercontent.com/nextauthjs/.github/main/CONTRIBUTING.md
  • Auth.js built-in Passkey provider: https://raw.githubusercontent.com/nextauthjs/next-auth/main/packages/core/src/providers/passkey.ts

About

CorePass Provider for Auth.js

Resources

License

Contributing

Stars

Watchers

Forks

Packages

No packages published