Ecosystem

nuxt-auth-idp

Build your own OpenApe Identity Provider as a Nuxt app.

@openape/nuxt-auth-idp

Drop-in Nuxt module that turns a Nuxt app into a full OpenApe IdP — WebAuthn passkey login, SSH-key agent auth, OAuth/OIDC endpoints, grant approval flows, federation, and admin UI. Counterpart to @openape/nuxt-auth-sp on the service-provider side.

Pair it with a storage backend of your choice: the module ships with an in-memory store suitable for development, and the openape-free-idp reference app wires it to Drizzle + libsql/Turso for persistent production hosting.

Quick Start

1. Install

pnpm add @openape/nuxt-auth-idp

2. Add to nuxt.config.ts

export default defineNuxtConfig({
  modules: ['@openape/nuxt-auth-idp'],
  openapeIdp: {
    issuer: 'https://id.example.com',
    rpName: 'Example IdP',
    sessionSecret: '', // set NUXT_OPENAPE_IDP_SESSION_SECRET in prod
  },
})

In dev the module auto-generates a sessionSecret and derives rpID from the request host. In production you must set sessionSecret and adminEmails explicitly.

3. Register a storage backend

The module is storage-agnostic. Register each store from a server plugin (server/plugins/02.stores.ts):

import {
  defineUserStore,
  defineCredentialStore,
  defineKeyStore,
  defineCodeStore,
  defineRefreshTokenStore,
  defineJtiStore,
  defineWebAuthnChallengeStore,
  defineRegistrationUrlStore,
  defineGrantStore,
  defineGrantChallengeStore,
  defineSshKeyStore,
  defineShapeStore,
} from '#imports'

export default defineNitroPlugin(() => {
  defineUserStore(() => myUserStore)
  defineCredentialStore(() => myCredentialStore)
  // …register all stores
})

For a complete wired-up example (Drizzle + libsql), see apps/openape-free-idp/server/plugins/03.stores.ts.

Module Options

Full configuration surface in nuxt.config.ts:

openapeIdp: {
  // Security
  sessionSecret: '',            // 32+ char secret (required in prod)
  sessionMaxAge: 604800,        // session cookie TTL in seconds (7 days)
  managementToken: '',          // bearer for management endpoints
  adminEmails: '',              // comma-separated list

  // Identity
  issuer: '',                   // OAuth `iss` claim; falls back to request origin
  rpName: '',                   // WebAuthn RP display name
  rpID: '',                     // WebAuthn RP ID; falls back to request host
  rpOrigin: '',                 // WebAuthn origin; falls back to request origin
  rpHostAllowList: '',          // comma-separated; see Multi-Tenant below

  // WebAuthn policy
  requireUserVerification: false,
  residentKey: 'preferred',     // 'preferred' | 'required' | 'discouraged'
  attestationType: 'none',      // 'none' | 'indirect' | 'direct' | 'enterprise'

  // Routes (which groups to register)
  routes: true,                 // or { auth, oauth, grants, admin, agent }
  pages: true,                  // register /login, /register, /account, /admin etc.

  // Grants
  grants: {
    enablePages: true,          // register /grants, /grant-approval, /enroll pages
    storageKey: 'openape-grants',
  },

  // Federation
  federationProviders: '',      // JSON string — see Federation below

  // Embedding
  allowedFrameAncestors: '',    // space-separated origins; defaults to frame-ancestors 'none'

  // Storage namespace (used by the default in-memory store)
  storageKey: 'openape-idp',
}

All options are settable via environment variables following Nuxt's runtimeConfig convention (NUXT_OPENAPE_IDP_*):

VariableConfig key
NUXT_OPENAPE_IDP_SESSION_SECRETsessionSecret
NUXT_OPENAPE_IDP_SESSION_MAX_AGEsessionMaxAge
NUXT_OPENAPE_IDP_MANAGEMENT_TOKENmanagementToken
NUXT_OPENAPE_IDP_ADMIN_EMAILSadminEmails
NUXT_OPENAPE_IDP_ISSUERissuer
NUXT_OPENAPE_IDP_RP_NAMErpName
NUXT_OPENAPE_IDP_RP_IDrpID
NUXT_OPENAPE_IDP_RP_ORIGINrpOrigin
NUXT_OPENAPE_IDP_RP_HOST_ALLOW_LISTrpHostAllowList
NUXT_OPENAPE_IDP_REQUIRE_USER_VERIFICATIONrequireUserVerification
NUXT_OPENAPE_IDP_RESIDENT_KEYresidentKey
NUXT_OPENAPE_IDP_ATTESTATION_TYPEattestationType
NUXT_OPENAPE_IDP_FEDERATION_PROVIDERSfederationProviders
NUXT_OPENAPE_IDP_ALLOWED_FRAME_ANCESTORSallowedFrameAncestors

Server API Routes

The module auto-registers the following routes (gated by the routes option):

Auth (routes.auth)

  • POST /api/session/login — cookie-session login
  • POST /api/session/logout
  • GET /api/session/ssh-keys — list caller's SSH keys
  • POST /api/session/ssh-keys — add an SSH key for the authenticated user
  • DELETE /api/session/ssh-keys/:keyId
  • POST /api/logout
  • GET /api/me — returns { email, name, isAdmin } for the cookie session

WebAuthn (under routes.auth)

  • POST /api/webauthn/register/options · POST /api/webauthn/register/verify
  • POST /api/webauthn/login/options · POST /api/webauthn/login/verify
  • GET /api/webauthn/credentials — list the caller's registered credentials
  • POST /api/webauthn/credentials/add/options · POST /api/webauthn/credentials/add/verify
  • DELETE /api/webauthn/credentials/:id

OAuth / OIDC (routes.oauth)

  • GET /authorize — OAuth authorization endpoint
  • POST /token — token exchange
  • POST /revoke
  • GET /userinfo
  • GET /.well-known/openid-configuration
  • GET /.well-known/jwks.json

Grants (routes.grants)

  • GET /api/grants · POST /api/grants · GET /api/grants/:id
  • POST /api/grants/:id/approve|deny|revoke|token|consume
  • POST /api/grants/verify — verify an AuthZ-JWT
  • POST /api/grants/authorize — per-call program authorization (see below)
  • POST /api/grants/batch
  • GET|POST /api/delegations · DELETE /api/delegations/:id · POST /api/delegations/:id/validate
  • GET /api/shapes · GET /api/shapes/:cliId · POST /api/shapes/resolve
  • GET|POST /api/standing-grants · DELETE /api/standing-grants/:id
  • GET /api/users/:email/agents

Per-call program authorization

POST /api/grants/authorize decides one program call. The requester authenticates with its agent access token (Authorization: Bearer …, the same token as for POST /api/grants) and sends only the exact command and the hash of the program file. It never sends authorization details: only the IdP resolves commands with Shapes. Discovery advertises the endpoint as openape_program_authorization_endpoint.

{
  "target_host": "macmini",
  "audience": "shapes",
  "program": { "name": "git", "sha256": "<64 lowercase hex characters of the file>" },
  "argv": ["-C", "/repo", "status"],
  "grant_type": "always",
  "summary": { "title": "Check the repository", "origin": "Mail digest" },
  "batch": { "id": "optional, as for POST /api/grants" }
}

program.name is the basename of the executable, argv the arguments without the executable (at most 256), grant_type is always (default) or once, summary and batch follow POST /api/grants. The body is at most 64 KiB, so a maximal body still fits the 128 KiB broker assertion that forwards it; a larger Content-Length is refused with 413 before the body is read. Unknown fields, including authorization_details and execution_context, are refused with 400. Only active requester accounts are decided for (403 otherwise).

The IdP executes this decision:

  1. Desktop-own executables. pnpm seed:shapes marks the shapes of operations the Pods desktop performs itself (pod-runtime, pod-http, pod-ssh) as virtual, taking them from scripts/desktop-own-shapes.json. Such shapes declare no tested versions or digests, and the seed fails if the registry lacks one of them. A program.name that names one is refused with 400, and shape selection never picks one. Their grants remain desktop-own operations through POST /api/grants.
  2. Wildcards. A * in an argument never becomes a selector glob: when a resolved selector value contains *, the call becomes an exact command bound to its argv hash.
  3. Shape. A shape whose tested digests (registry tested_digests) contain program.sha256 is used with version_tested: true. Otherwise the shape whose executable equals program.name is used with version_tested: false. The grant card then shows "version not tested". Without such a shape, or without a matching operation, the call becomes an exact command bound to the hash of [program.name, ...argv].
  4. Context. The IdP writes execution_context itself: argv, argv_hash, program_sha256, version_tested and informational adapter fields. POST /api/grants refuses program_sha256 and version_tested from clients.
  5. Coverage. An approved, unexpired grant of the same requester, broker binding, target host and audience covers the call when its details cover the resolved detail. Exact commands also need the same argv hash. A command without a shape also needs the same program hash when the grant records one. The adapter digest is never compared, so approvals carry over adapter updates. Standing grants and pre-approval hooks (YOLO) also decide for direct identities; YOLO takes the risk from the IdP's own resolved details. A denial or pending request matches by structured detail (CLI, operation, action and resource-chain selectors), not by the permission string.
  6. Answer.
    • 200 { "state": "approved", "grant_id", "authz_jwt", "expires_at" }.
    • 202 { "state": "pending", "grant_id", "approval_url" }. This answer reuses the requester's pending request for the same permission (and argv hash). Otherwise it creates one exactly like POST /api/grants, including notifications.
    • 403 problem+json with state: "denied" | "revoked" and grant_id, when the newest request for this permission was denied or revoked. No new request is created.

The command token (authz_jwt) is signed like every grant token, by the same issuer with the same JWKS. In addition to the usual claims, it carries grant_id, argv_hash, program_sha256 and target_host. Its exp is at most five minutes and never beyond the grant. The requester verifies the following before it executes: signature, iss, sub, target_host, argv_hash against the exact execution argv, and program_sha256 against the file it is about to run. Issuing a command token from a single-use grant marks it used in one conditional update, so a second call gets no token from it. The first POST /api/grants/:id/consume with that command token answers status: "consumed" (recorded with a compare-and-swap on consumed_jti); every later redemption, with the same or any other token, gets already_consumed, so /consume stays a replay guard.

Calls have their own budget per requester subject: 600 per minute by default, set with OPENAPE_RATE_LIMIT_MAX_AUTHORIZE. Exceeding it returns 429 with Retry-After. The budget never shares the sign-in or per-IP buckets; calls without a valid bearer token are charged to the per-IP agent bucket. On a broker IdP the call goes to the decision IdP as the signed authorize broker operation, with the same body and answer (see docs/operations/grant-brokering.md).

Agents (routes.agent)

  • POST /api/agent/challenge · POST /api/agent/authenticate · POST /api/agent/enroll
  • POST /api/auth/challenge · POST /api/auth/authenticate · POST /api/auth/enroll — unified endpoints that work for agents and humans with SSH keys

Admin (routes.admin, requires isAdmin)

  • /api/admin/users · /api/admin/users/:email · /api/admin/users/:email/credentials|ssh-keys
  • /api/admin/agents · /api/admin/agents/:id
  • /api/admin/sessions · /api/admin/sessions/:familyId · /api/admin/sessions/user/:email
  • /api/admin/registration-urls · /api/admin/registration-urls/:token

Federation (if federationProviders is set)

  • GET /auth/federated/:providerId · /auth/federated/:providerId/callback
  • GET /api/federation/providers

Built-in Pages

When pages: true (default), the module registers these Vue pages. A consuming app can override any by defining its own page with the same path:

  • /login, /register, /account, /admin
  • /grants, /grant-approval, /enroll (when grants.enablePages: true)
  • /agents, /agents/:email

Composables

useIdpAuth()

const { user, loading, fetchUser, logout } = useIdpAuth()
PropertyTypeDescription
userRef<{ email, name, isAdmin } | null>Cookie-session claims for the current user
loadingRef<boolean>Whether fetchUser() is in flight
fetchUser()() => Promise<void>Hydrate user from /api/me
logout()() => Promise<void>Destroy the cookie session

useWebAuthn()

Helper for browser-side passkey enrollment and login flows (wraps @simplewebauthn/browser). Used by the built-in pages; available for custom UIs.

Multi-Tenant (Host-Derived RP)

The module supports hosting multiple IdP domains from a single deployment. RP config is resolved per request: if the incoming Host header is in rpHostAllowList, the module overrides the static rpID/rpOrigin/issuer for that request.

openapeIdp: {
  rpHostAllowList: 'id.example.com,id.example.at',
  // rpID / rpOrigin / issuer are vestigial when the allow-list covers every host
}

The consuming app supplies a middleware that populates event.context.openapeRpConfig — all WebAuthn handlers read from that seam first before falling back to the static module config. See the full walkthrough in Multi-Tenant IdP.

Storage Backend Interfaces

Each define<Name>Store() accepts a factory that receives the current H3Event and returns an instance of the matching interface from @openape/auth (user, credential, key, code, refresh-token, jti, webauthn-challenge, registration-url) or @openape/grants (shape). The IdP module only imports the shape — you control persistence.

The reference openape-free-idp implements each store against Drizzle + libsql (Turso or local SQLite); fork that pattern if you want a persistent IdP quickly, or ship your own backend (Postgres, DynamoDB, etc).

Security Headers

The module installs these headers on every request:

  • X-Content-Type-Options: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin
  • X-Frame-Options: DENY + Content-Security-Policy: frame-ancestors 'none' — unless allowedFrameAncestors is set, in which case X-Frame-Options is dropped and CSP carries frame-ancestors 'self' <origins>.

Session, auth, and admin routes additionally receive Cache-Control: no-store.