Authentication¶
An app signs users in by sending them to Cirro's authorize page. Cirro shows the user which app is asking and what it may do, the user signs in with their own account, and the app gets back a token that acts as them. The app has no secret, and doesn't need to be given a client ID: it asks the tenant for the one registered for its callback URL.
The flow¶
A Public App uses the authorization code flow with PKCE. Each tenant publishes its endpoints in an OAuth metadata document at <tenant>/.well-known/oauth-authorization-server (RFC 8414): the authorize step goes to Cirro's web address, and the token step to the tenant's token server. An OAuth library runs steps 2 to 7; your own code starts at step 8.
sequenceDiagram
participant App as Your app
participant Cirro as Cirro authorize page
participant Login as Token server
participant API as Cirro API
App->>Cirro: 1. GET /.well-known/oauth-authorization-server (no token)
App->>Cirro: POST registration_endpoint with the callback URL, returns the client ID
App->>Cirro: 2. Redirect to /authorize with client ID + PKCE challenge
Note over Cirro: 3. Shows the app's name, publisher and permissions,<br/>and the user picks how to sign in
Cirro->>Login: 4. Hands off to the tenant's sign-in
Login-->>App: 5. Redirect back to your callback URL with a code
App->>Login: 6. Exchange code + PKCE verifier at the token endpoint
Login-->>App: 7. Access, ID and refresh tokens
App->>API: 8. API calls with Authorization: Bearer
- Step 1 needs no token. The metadata document lists
authorization_endpoint(Cirro's authorize page),token_endpoint,revocation_endpointandissuer, and answers browsers from any origin. Read endpoints from it rather than building them. Once the user is signed in, the template reads the tenant's display name withdata.system.info()(tenantInfo.name). - The client ID comes from the tenant. The app POSTs its callback URL to the metadata document's
registration_endpoint(dynamic client registration, RFC 7591), and the tenant answers with the client ID of the registration that lists it. A tenant admin sets that registration up with discovery enabled. The tenant answers 401 if no discoverable registration lists the callback URL, and 409 if more than one does. During local development with your own App Developer registration, which isn't discoverable, the app uses that registration's client ID instead. - Step 3 is Cirro's consent screen. It shows the registration's name, logo, publisher and requested permissions, and an "Official" badge for apps Cirro publishes, and lists the tenant's sign-in providers. The user must be signed in to Cirro to see it; a signed-out user is taken to Cirro's sign-in first. It appears on every sign-in: Cirro doesn't remember that the user approved the app before. Only a reload, while the app still holds the user's tokens, skips it.
- PKCE replaces the client secret. The library sends only a hash of a random verifier in step 2 and proves possession in step 6, so a stolen code is useless on its own.
- Only
openid. Public Apps allow theopenidscope and no other. - Renewal is silent. The library uses the refresh token from step 7 to get new access tokens before they expire. By default access tokens last 15 minutes and refresh tokens a day, so a user who comes back after a day signs in again. Refresh tokens aren't rotated.
The template's sign-in¶
The app template implements the flow with oidc-client-ts, in src/auth.ts, configured from the tenant's metadata document:
import { UserManager } from "oidc-client-ts";
const metadata = await (await fetch(`${cirroUrl}/.well-known/oauth-authorization-server`)).json();
const userManager = new UserManager({
authority: cirroUrl,
metadata, // authorize, token, revocation and registration endpoints
// VITE_CIRRO_CLIENT_ID during local development; otherwise asks registration_endpoint for the
// client ID registered for REDIRECT_URI (registeredClientId in src/auth.ts)
client_id: devClientId || await registeredClientId(metadata.registration_endpoint),
redirect_uri: REDIRECT_URI,
response_type: "code",
scope: "openid",
loadUserInfo: false,
automaticSilentRenew: true,
});
src/main.tsx finishes a returning sign-in (signinRedirectCallback() when the URL has code and state), shows a Sign in with Cirro button otherwise (signinRedirect()), and gives DataService a token getter that reads the current access token from the library, renewing it with the refresh token first if it has expired (a tab that slept past automatic renewal). A user whose token can't be renewed is treated as signed out. The username is the ID token's cognito:username claim.
What to know about it:
- The callback URL is unique to the app, and must match the registration exactly. The template returns to its origin plus its own path, Vite's
baseinvite.config.ts(/cirro-app/until you rename it), for examplehttp://localhost:5173/qc-review/. The path keeps the callback URL unique even when several apps share a host. Register that exact URL, trailing slash included: Cognito's authorize step treatshttps://host/appandhttps://host/app/alike, but the end of a SAML sign-in (a Microsoft or other SAML identity provider) compares exactly and fails withInvalid SAML response received: RedirectUri is not registered with the client. - Show a failed return. A page load with
?error=is a failed sign-in, usually a registration mismatch. The template shows the error instead of offering to sign in again, so a broken registration can't loop. - Tokens live in the library's session storage (oidc-client-ts's default), so a reload keeps the user signed in without another trip through the consent screen. Your own code never stores them. Browser storage is per origin, so an app served from an origin of its own (its own domain or subdomain) can keep the login across tabs and browser restarts by passing
userStore: new WebStorageStateStore({ store: window.localStorage }); ask the tenant's Cirro admins to shorten the app client's refresh-token lifetime when you do, since the login then persists on disk. Keep the default on an origin shared with other sites, such as agithub.ioPages site, where every site on it could read the tokens. - Signing out revokes the refresh token at the
revocation_endpoint(the token server revokes refresh tokens only) and removes the user from the library, which it does even when revocation fails; the app then shows the revocation error instead of reloading. That ends the app's session, not the user's Cirro session; signing in to the app again goes through Cirro's authorize page again.
Using another OAuth library¶
The metadata document's issuer is the tenant's Cognito user pool (https://cognito-idp.<region>.amazonaws.com/<pool>), not the Cirro address the document is published at, and the ID tokens carry the same issuer. oidc-client-ts doesn't compare the two. Libraries that do, such as oauth4webapi, refuse the document in their discovery step. With such a library, fetch the document yourself, as the template does, and pass it to the library as the server's settings instead of using its discovery step.
Checking it works¶
- Load the app signed out and click Sign in with Cirro: Cirro's authorize page should name your app and list its permissions.
- After sign-in, call
data.projects.getProjects()and confirm the list matches what the user sees in Cirro. - Once the app is approved, sign in as a second user.
- Leave the tab past the access-token lifetime and confirm calls still succeed.
If something fails, check troubleshooting before changing code; most failures are registration mismatches.