Authentication
Anonymous by default, Quartal Hub with one line of config, or any standards-compliant OAuth2 / OIDC server.
A plugin has one authentication mode, set with auth in qrtl.config.ts. It applies to the
whole server app — both the REST API (/api/*) and the MCP server(s) (/mcp). Documentation
pages, widget pages and static assets always stay public (the sandboxed widget iframe carries no
credentials).
Anonymous by default
export default defineQrtlConfig({
auth: "anon", // the default — you can omit it
});
With "anon" (the default) there is no authentication at all: anyone who can reach the plugin can
call every tool. This is the right mode for public data, demos and internal services behind their
own network boundary. Tool methods receive only their input parameter.
Quartal Hub: one line
export default defineQrtlConfig({
auth: "quartal-hub",
deploy: { org: "my-org", app: "my-plugin" },
});
"quartal-hub" connects the plugin to the shared authentication of Quartal Hub (powered by
the Quartal IAM server). It is deliberately zero-config — every plugin uses the same fixed values:
| What | Value |
|---|---|
| Issuer | The Quartal Hub test IAM server (https://iam2026.test.qrtl.com/realms/salaxy-test) |
| Scope | quartal-hub-test (plus the OIDC user-info scopes profile email) |
| Audience | https://hub.test.qrtl.com (a fixed identifier, not a served URL) |
| Resource (RFC 9728) | Derived from each request’s origin — http://localhost:4321 in dev, the deployed URL in production, with no configuration |
What you get:
- Incoming requests to
/api/*and/mcpmust carry an OAuth2 JWT bearer token, verified against Quartal IAM. - Tool methods get a
QuartalPluginContextas their second parameter:uid,email,orgs, anavatar(display name, initials, picture) and the rawtokenfor calling downstream APIs. - MCP clients such as Claude discover the login flow automatically: the plugin serves the OAuth
Protected Resource Metadata document (RFC 9728) and answers unauthenticated calls with a
standards-compliant
WWW-Authenticatechallenge, so the client registers itself and sends the user through the normal browser login. - The Swagger UI on the plugin’s docs site gets an Authorize dialog for testing with a real login.
The only environment variable this mode honors is OAUTH_ISSUER — set it to point at another
Quartal IAM instance (e.g. a dev server). All other OAUTH_* variables are ignored; to control
those details, use auth: "custom".
The scaffolder sets this up for you: answer yes to the authentication question (or pass --auth
to pnpm create @quartal/plugin).
Logging in on the docs site
An authenticated plugin’s docs site shows a Log in button in the top bar. It runs the same OAuth flow MCP clients use — authorization code + PKCE with a CIMD client:
- A deployed plugin is its own OAuth client: it serves its client metadata document at
/.well-known/oauth/client-metadata.json, and that URL is theclient_id. - On localhost, the authorization server cannot fetch a localhost URL, so the login uses the
shared metadata document published at
https://plugin.quartal.com/.well-known/oauth/client-metadata.json, whose redirect URI ishttp://localhost:4321/oauth/callback(the default dev port).
The flow is server-driven — /oauth/login redirects to the authorization server and
/oauth/callback exchanges the code server-side — so it does not depend on the authorization
server’s CORS behavior. The resulting token is held in the browser session and injected into
the Swagger UI’s Try it out requests automatically; the Authorize dialog still accepts a
manually pasted bearer token, which then takes precedence.
In "custom" mode the same flow uses the pre-registered OAUTH_CLIENT_ID instead of CIMD —
see below.
Test tier. The shared
quartal-hub-testscope and audience mean a token issued for one quartal-hub plugin is currently valid at every quartal-hub plugin. That is acceptable for test data only. Before production use, this moves to per-plugin audiences — natively via RFC 8707 resource indicators once Keycloak 26.8 ships them, otherwise via per-plugin client scopes provisioned at deploy time.
Custom OAuth2 / OIDC server
export default defineQrtlConfig({
auth: "custom",
});
"custom" is the same JWT bearer authentication with all details supplied by environment
variables — for connecting to your own authorization server (Auth0, Microsoft Entra ID,
Keycloak, Okta, Zitadel, …):
| Variable | Required | Purpose |
|---|---|---|
OAUTH_ISSUER |
yes | The authorization server’s issuer URL — the expected iss claim, and the base for OIDC discovery (<issuer>/.well-known/openid-configuration). |
OAUTH_AUDIENCE |
one of these | The expected aud claim of incoming access tokens. Defaults to OAUTH_RESOURCE. |
OAUTH_RESOURCE |
one of these | The canonical URI of this plugin as an OAuth resource server (RFC 8707), e.g. https://my-plugin.example.com. Published in the resource metadata; also the default audience. When unset, the resource is derived from each request’s origin. |
OAUTH_SCOPE |
no | Scope(s) this plugin requires (space-separated). Advertised in the 401 challenge and the resource metadata. The OIDC user-info scopes profile email are always added. |
OAUTH_JWKS_URI |
no | Explicit JWKS endpoint. Default: discovered from the OIDC configuration. |
OAUTH_TOKEN_URL |
no | Explicit token endpoint (used by the docs-site login flow). Default: discovered. |
OAUTH_CLIENT_ID |
for docs login | Pre-registered public client id used by the docs-site Log in button. Auth0 and Entra ID do not support CIMD, so register the plugin as a public (PKCE, no secret) client with redirect URI <plugin-origin>/oauth/callback and put its id here. Without it, the docs-site login attempts CIMD. |
Set the variables in the shell / hosting platform the plugin runs in.
Auth0
Create an API in the Auth0 dashboard (Applications → APIs). Its Identifier is the audience Auth0 stamps into access tokens — use your plugin’s canonical URL. Then:
OAUTH_ISSUER=https://YOUR_TENANT.auth0.com/
OAUTH_AUDIENCE=https://my-plugin.example.com # the API Identifier, verbatim
OAUTH_CLIENT_ID=... # for the docs-site login, see below
Notes:
- The issuer ends with a slash — Auth0’s
issclaim includes it, and the value must match exactly. - Auth0 signs with RS256 by default; discovery provides the JWKS. Nothing else to set.
- For interactive MCP clients, enable Dynamic Application Registration (tenant settings) so clients can register themselves after discovering the server through the RFC 9728 metadata.
- For the docs-site Log in button, create a Native application with callback URL
<plugin-origin>/oauth/callbackand set its client id asOAUTH_CLIENT_ID.
Microsoft Entra ID
Register an application (Entra admin center → App registrations), then Expose an API to get an
Application ID URI (api://<app-client-id>) and add at least one scope. Then:
OAUTH_ISSUER=https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0
OAUTH_AUDIENCE=api://YOUR_APP_CLIENT_ID # the Application ID URI
OAUTH_SCOPE=api://YOUR_APP_CLIENT_ID/.default # or a named scope you exposed
OAUTH_CLIENT_ID=YOUR_CLIENT_APP_ID # for the docs-site login, see below
Notes:
- Use the v2.0 issuer (shown above); v1.0 tokens carry a different
issformat. - The
audof v2.0 access tokens is the Application ID URI (or the bare client-id GUID, depending on how the client requests the scope) — setOAUTH_AUDIENCEto what your tokens actually carry. - Entra ID does not support dynamic client registration: every MCP client (Claude, MCPJam, …) must be pre-registered as its own app registration with its redirect URIs. This makes Entra a poor fit for ad-hoc MCP clients; it works well for a fixed, known set of clients.
- For the docs-site Log in button, add
<plugin-origin>/oauth/callbackas a redirect URI on the Mobile and desktop applications platform (a public client — the token exchange happens server-side without a client secret; the SPA platform would reject it, and Web would demand a secret) and set that app registration’s client id asOAUTH_CLIENT_ID.
What the OAuth server must provide
Any standards-compliant OAuth2 / OIDC server works, as long as it can:
- Serve OIDC discovery at
<issuer>/.well-known/openid-configurationwith ajwks_uriandtoken_endpoint(or you setOAUTH_JWKS_URI/OAUTH_TOKEN_URLexplicitly). - Issue JWT access tokens signed with
RS256orES256, with theissclaim matching the issuer and theaudclaim matching your configured audience/resource. - Ideally include the standard user-info claims —
sub,email,name/preferred_username,picture— which map to theQuartalPluginContextyour tools receive. - For interactive MCP clients (Claude and others): support dynamic client registration or Client ID Metadata Documents, so the client can register itself after discovering the server through the plugin’s RFC 9728 metadata. Without it, you must pre-register each client manually.
Can these be set in qrtl.config.ts?
No — deliberately. qrtl.config.ts sets only the mode (auth: "anon" | "quartal-hub" | "custom");
the OAuth details stay out of it because they are deployment-specific — a staging and a production
deployment of the same plugin use different issuers, resource URIs and client ids, and those
belong in each environment rather than in a file committed to the repository. Use environment
variables per deployment.
If you host the plugin app programmatically (outside the Astro integration), you can pass a typed
OAuthOptions object to getAuthApp(config, oauth) instead — it supports everything the
environment variables do, plus overrides such as algorithms, additionalScopes and a custom
claimsToContext mapper from JWT claims to the QuartalPluginContext.
What is protected, exactly
| Surface | With "quartal-hub" / "custom" |
|---|---|
POST /api/<Class>/<method> |
Bearer token required. |
ALL /mcp, /mcp/<server> |
Bearer token required; unauthenticated calls get the RFC 9728 challenge. |
GET /.well-known/oauth-protected-resource |
Public (this is how clients find your auth server). |
GET /.well-known/oauth/client-metadata.json, /oauth/login, /oauth/callback |
Public (the docs-site login flow — how a browser session obtains a token). |
Docs site, /widgets/*, /widget-assets/*, public/ |
Public. |
Changing auth is picked up on dev-server restart; qrtl.config.ts is watched, so saving it
restarts automatically. See also the configuration reference.