nuxt-auth-idp
@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_*):
| Variable | Config key |
|---|---|
NUXT_OPENAPE_IDP_SESSION_SECRET | sessionSecret |
NUXT_OPENAPE_IDP_SESSION_MAX_AGE | sessionMaxAge |
NUXT_OPENAPE_IDP_MANAGEMENT_TOKEN | managementToken |
NUXT_OPENAPE_IDP_ADMIN_EMAILS | adminEmails |
NUXT_OPENAPE_IDP_ISSUER | issuer |
NUXT_OPENAPE_IDP_RP_NAME | rpName |
NUXT_OPENAPE_IDP_RP_ID | rpID |
NUXT_OPENAPE_IDP_RP_ORIGIN | rpOrigin |
NUXT_OPENAPE_IDP_RP_HOST_ALLOW_LIST | rpHostAllowList |
NUXT_OPENAPE_IDP_REQUIRE_USER_VERIFICATION | requireUserVerification |
NUXT_OPENAPE_IDP_RESIDENT_KEY | residentKey |
NUXT_OPENAPE_IDP_ATTESTATION_TYPE | attestationType |
NUXT_OPENAPE_IDP_FEDERATION_PROVIDERS | federationProviders |
NUXT_OPENAPE_IDP_ALLOWED_FRAME_ANCESTORS | allowedFrameAncestors |
Server API Routes
The module auto-registers the following routes (gated by the routes option):
Auth (routes.auth)
POST /api/session/login— cookie-session loginPOST /api/session/logoutGET /api/session/ssh-keys— list caller's SSH keysPOST /api/session/ssh-keys— add an SSH key for the authenticated userDELETE /api/session/ssh-keys/:keyIdPOST /api/logoutGET /api/me— returns{ email, name, isAdmin }for the cookie session
WebAuthn (under routes.auth)
POST /api/webauthn/register/options·POST /api/webauthn/register/verifyPOST /api/webauthn/login/options·POST /api/webauthn/login/verifyGET /api/webauthn/credentials— list the caller's registered credentialsPOST /api/webauthn/credentials/add/options·POST /api/webauthn/credentials/add/verifyDELETE /api/webauthn/credentials/:id
OAuth / OIDC (routes.oauth)
GET /authorize— OAuth authorization endpointPOST /token— token exchangePOST /revokeGET /userinfoGET /.well-known/openid-configurationGET /.well-known/jwks.json
Grants (routes.grants)
GET /api/grants·POST /api/grants·GET /api/grants/:idPOST /api/grants/:id/approve|deny|revoke|token|consumePOST /api/grants/verify— verify an AuthZ-JWTPOST /api/grants/authorize— per-call program authorization (see below)POST /api/grants/batchGET|POST /api/delegations·DELETE /api/delegations/:id·POST /api/delegations/:id/validateGET /api/shapes·GET /api/shapes/:cliId·POST /api/shapes/resolveGET|POST /api/standing-grants·DELETE /api/standing-grants/:idGET /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:
- Desktop-own executables.
pnpm seed:shapesmarks the shapes of operations the Pods desktop performs itself (pod-runtime,pod-http,pod-ssh) asvirtual, taking them fromscripts/desktop-own-shapes.json. Such shapes declare no tested versions or digests, and the seed fails if the registry lacks one of them. Aprogram.namethat names one is refused with 400, and shape selection never picks one. Their grants remain desktop-own operations throughPOST /api/grants. - 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. - Shape. A shape whose tested digests (registry
tested_digests) containprogram.sha256is used withversion_tested: true. Otherwise the shape whoseexecutableequalsprogram.nameis used withversion_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]. - Context. The IdP writes
execution_contextitself:argv,argv_hash,program_sha256,version_testedand informational adapter fields.POST /api/grantsrefusesprogram_sha256andversion_testedfrom clients. - 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.
- 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 likePOST /api/grants, including notifications. - 403 problem+json with
state: "denied" | "revoked"andgrant_id, when the newest request for this permission was denied or revoked. No new request is created.
- 200
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/enrollPOST /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/callbackGET /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(whengrants.enablePages: true)/agents,/agents/:email
Composables
useIdpAuth()
const { user, loading, fetchUser, logout } = useIdpAuth()
| Property | Type | Description |
|---|---|---|
user | Ref<{ email, name, isAdmin } | null> | Cookie-session claims for the current user |
loading | Ref<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: nosniffReferrer-Policy: strict-origin-when-cross-originX-Frame-Options: DENY+Content-Security-Policy: frame-ancestors 'none'— unlessallowedFrameAncestorsis set, in which case X-Frame-Options is dropped and CSP carriesframe-ancestors 'self' <origins>.
Session, auth, and admin routes additionally receive Cache-Control: no-store.
Related
@openape/nuxt-auth-sp— Service-provider counterpart- Grants — Grant model and AuthZ-JWT structure
- Multi-Tenant IdP — Host-derived RP config