@nyuro/identity-clerk
v0.3.0
Published
Framework-neutral authenticator: Clerk session JWTs (verified locally) and Nyuro API keys to a Principal.
Readme
@nyuro/identity-clerk
Framework-neutral authenticator for Nyuro services. It turns a request's headers into a
Principal (the @nyuro/flow-ports shape) and nothing else: it identifies, it does not
authorise. Server-side only.
- Bearer only. Reads
Authorization: Bearer …. Cookies andx-request-fromare never read. - Clerk session JWT (three segments): verified locally against the instance JWKS or a pinned
PEM key. RS256 only (
noneandHS*are refused before any key lookup),issmust match,azpmust be in the allowlist,exp/nbfchecked with a small clock skew (default 5 s), pending sessions (sts: pending, a user not in an org yet) refused. Clerk v1 (org_id,org_role, …) and v2 (o: { id, rol, slg, per }) org claims are both read. - Nyuro API key (
nyk_…, structured asnyk_<env>_<keyId>_<secret>): handed toverifyApiKey. Keys are bound to one workspace. - Active workspace from
X-Nyuro-Workspace(a UUID). The resolver checks membership; the authenticator then re-checks that the principal is for that workspace, so a resolver that ignores the header cannot leak another workspace. - Link by Clerk user id only, never by email. Principals are cached per (Clerk user, Clerk org, workspace) with a TTL and single-flight.
Usage
import { createAuthenticator, IdentityError } from '@nyuro/identity-clerk'
import { createPlatformClient } from '@nyuro/sdk/platform'
const platform = createPlatformClient({
baseUrl: process.env.NYURO_PLATFORM_URL!, // …/platform/v1
serviceKey: process.env.NYURO_PLATFORM_KEY! // from the secret manager
})
const auth = createAuthenticator({
jwks: `${process.env.CLERK_FRONTEND_API}/.well-known/jwks.json`, // or jwtKey: process.env.CLERK_JWT_KEY (PEM)
issuer: process.env.CLERK_FRONTEND_API!, // https://clerk.example.com
authorizedParties: ['https://flow.nyuro.ai'],
platform, // default resolvers: POST /identity/resolve
onReject: (reason) => log.info({ reason }, 'auth rejected')
})
// Any framework: pass something with `headers`.
app.use(async (req, res, next) => {
try {
req.principal = await auth.authenticate(req) // null → anonymous (the route policy decides)
next()
} catch (err) {
if (err instanceof IdentityError) {
const status = { TENANT_MISMATCH: 403, DENIED: 403, TENANT_REQUIRED: 400, UPSTREAM_UNAVAILABLE: 503 }[err.code]
return res.status(status).json({ code: err.code })
}
next(err)
}
})The returned object also satisfies IdentityPort (authenticate, verifyApiKey).
Outcomes
| Situation | Result |
|---|---|
| No Authorization, non-Bearer scheme, malformed / expired / forged token, wrong iss or azp, unknown key, Clerk user not linked | null (401). onReject gets the reason. |
| X-Nyuro-Workspace malformed, repeated, or not available to the caller | IdentityError('TENANT_MISMATCH') (403) |
| Platform says not a member | IdentityError('DENIED') (403) |
| User has no org and no workspace was chosen | IdentityError('TENANT_REQUIRED') (400) |
| JWKS never reachable, platform down, resolver returned something invalid | IdentityError('UPSTREAM_UNAVAILABLE') (503, fail closed) |
Options
| Option | Default | |
|---|---|---|
| jwks / jwtKey | — | exactly one: JWKS URL (https) or inline set, or Clerk's PEM public key |
| issuer | — | required |
| authorizedParties | — | required, non-empty; tokens without azp are refused |
| resolvePrincipal | platform resolver | ({ token, session, workspaceId }) => Principal \| { principal, ttlSeconds } \| null |
| verifyApiKey | platform verifier | (key, { workspaceId, parsed }) => …; without it and without platform, nyk_ keys are refused |
| platform | — | a PlatformClient from @nyuro/sdk/platform (structurally typed; the SDK is not a dependency) |
| cache | { principalTtlSeconds: 30, apiKeyTtlSeconds: 300, negativeTtlSeconds: 30, maxEntries: 10000 } | or false; a resolver's ttlSeconds wins when lower |
| clockSkewSeconds | 5 | capped at 60 |
| jwksOptions | { cooldownSeconds: 30, maxAgeSeconds: 3600, timeoutMs: 5000 } | an unknown kid refreshes at most once per cooldown |
| allowPendingSessions | false | |
auth.invalidate({ clerkUserId }) drops a user's cached principals (call it from a membership
webhook); auth.invalidate() drops everything. Revocation otherwise lags by at most the TTL.
Dependency
One runtime dependency: jose (zero dependencies of its own,
Web Crypto based, widely audited). It does the RS256 signature check and the registered-claim
validation; the JWKS fetching, kid refresh rate limiting, azp and org-claim handling are ours.
Hand-rolling JWS verification is where alg-confusion bugs come from, so we do not.
jose 6 is ESM-only; the CJS build loads it with require(esm), which needs Node ≥ 22.12
(our engines floor).
