8.1.145-stable Switch to dev

openid

@library("openid", "0.0.0");

OpenID Connect (OIDC) single sign-on for GreyCat. Multi-provider, secure-by-default.

Supports two integration shapes:

  • Redirect flow (server-driven). The browser hits the shipped index.html (the provider picker), is sent to the provider, and returns to that same page — which detects the ?code=&state= query and finishes the login. GreyCat runs Authorization Code + PKCE itself; verifies state, nonce, signature, iss, aud, exp; resolves the ID token to a runtime::Identity; impersonates that Identity on the response (cookie set automatically).
  • Token exchange. A client that already holds an ID token calls Openid::token_login(provider, jwt) to trade it for a GreyCat session.

Quick start

In your project:

@library("openid", "0.0.0");

fn main() {
    Openid::register("google", OidcProvider {
        issuer: "https://accounts.google.com",
        client_id: System::getEnv("GOOGLE_CLIENT_ID"),
        client_secret: System::getEnv("GOOGLE_CLIENT_SECRET"),
        scopes: ["openid", "email", "profile"],
        redirect_uri: "https://app.example.com/",
        // auto_create_identity: true,  // create missing Identities on first login
    });
    // optional — without this, OpenidHelpers::default_match runs
    // (claims.preferred_username → claims.email → reject; or auto-create
    // when the provider has auto_create_identity = true)
    // Openid::set_hook(my_resolver);
}

Point redirect_uri at your site root. GreyCat serves an embedded index.html there which handles password login, the OpenID provider picker, and the post-redirect callback all in one page. Drop your own webroot/index.html next to project.gcl to override it; the embedded fallback is used when none is found.

Custom resolver

If default_match doesn’t fit (auto-provisioning, role assignment, domain allow-list, etc.), register a hook:

fn my_resolver(provider_id: String, r: OidcResult): Identity? {
    var email = r.claims.email;
    if (email == null || !email.endsWith("@example.com")) {
        return null; // reject
    }
    return OpenidHelpers::ensure(email, "user");
}

fn main() {
    Openid::register(...);
    Openid::set_hook(my_resolver);
}

The hook signature is fn(provider_id: String, r: OidcResult): Identity?: return the Identity to log in as, or null to reject the login. A hook that throws fails the login — the pending-login state is already spent, so the user has to restart the flow. Reject by returning null.

Mapping IdP roles onto GreyCat roles

The hook may create identities and set their role, so the IdP can stay the authority on every login rather than only the first one:

fn role_hook(provider_id: String, r: OidcResult): Identity? {
    var mapping = Map<String, String> {};
    mapping.set("keycloak-admins", "admin");
    mapping.set("keycloak-staff", "user");

    var role = OpenidHelpers::role_from_groups(r.claims.groups, mapping, "user");
    var name = r.claims.preferred_username ?? r.claims.email!!;
    return OpenidHelpers::ensure_role(name, role);   // creates or re-roles
}

ensure_role is the one to reach for: ensure applies its role only when it creates the Identity, so a user whose IdP groups changed would keep their first-login role forever. Any role written before the hook returns is reflected in the session that login mints. Map only to roles you declare with @role — creating an Identity with an unknown role name throws.

Where the roles actually live

claims — including claims.groups and claims.raw — is the ID token payload. Keycloak’s built-in realm roles / client roles mappers ship with “Add to ID token” off, and a client role only ever appears under resource_access.<client>.roles, so on an untouched realm the user’s roles are in the access token exclusively. Two ways out:

  • add a mapper with “Add to ID token” on (see openid/keycloak.sh for a groups mapper doing exactly this), and read claims.groups; or
  • read r.access_token in the hook and decode the roles yourself — no realm change needed.

access_token / refresh_token are set on the redirect flow (Openid::callback) and are null under Openid::token_login, which is handed a bare ID token and never sees a token endpoint. They are bearer credentials: don’t log the OidcResult.

Helpers

type OpenidHelpers exposes opt-in utilities:

  • default_match(provider_id, r): Identity? — the default resolver.
  • ensure(name, role): Identity? — find-or-create; role applies at creation only.
  • ensure_role(name, role): Identity? — find-or-create and re-role, so the mapping is re-applied on every login.
  • sync_role(identity, role): bool — re-role without ever throwing; false when the role could not be applied. A role mismatch never costs a login.
  • stable_name(provider_id, claims): String"google:1234567890"-style collision-proof name from claims.sub.
  • role_from_groups(groups, mapping, default_role): String — map provider groups to a GreyCat role.

Logout

await runtime.Identity.logout();                      // clears the cookie
const url = await openid.Openid.end_session_url(...); // RP-initiated logout URL
if (url) location.replace(url);

end_session_url(provider_id, id_token_hint, post_logout_redirect_uri?) returns null when the provider has no end_session_endpoint.

Endpoints

All @expose @permission("public"):

  • Openid::providers(): Array<String> — list registered provider ids.
  • Openid::public_config(id): OidcPublicConfig? — secret-free provider config (issuer, client_id, redirect_uri, scopes) for browser clients driving the token-exchange flow. null if not registered. Never includes the client_secret.
  • Openid::login(provider_id, return_to?): String — start the redirect flow, returns the authorization URL.
  • Openid::callback(code, state): String — finish the redirect flow, returns the return_to from the originating login().
  • Openid::token_login(provider_id, jwt) — verify an ID token and impersonate.
  • Openid::end_session_url(provider_id, id_token_hint, post_logout_redirect_uri?): String?

Browser SDK (token-exchange flow)

openid/webroot/openid.ts is a zero-dependency, fully-typed ES-module helper for the token-exchange flow: it runs Authorization Code + PKCE in the browser against the provider, validates state + nonce locally, then calls Openid::token_login to obtain the GreyCat session cookie. The OAuth client must be a public client (no secret), and the provider’s authorization / token / JWKS endpoints must allow CORS for your frontend origin. Bundle it into webroot/ like any TypeScript module (it needs the DOM lib types).

import "@greycat/web";

// Hydrate issuer/client_id/redirect_uri/scopes from the server (single source
// of truth) via Openid::public_config:
const oidc = await gc.sdk.OpenidClient.fromProvider("keycloak");

const result = await oidc.handleRedirect(); // finishes a redirect if present
if (!result) {
  signInButton.onclick = () => oidc.login(); // otherwise start one
}

// later:
await oidc.logout(); // clears cookie + provider logout

OpenidClient.listProviders() returns the registered ids for a login picker; new OpenidClient({ provider, issuer, clientId, ... }) configures everything client-side without the public_config lookup.

Notes

  • Pending-login state (state / nonce / pkce_verifier / return_to) lives in a native in-memory TTL map (~5 minutes). Single-process deployment assumed.
  • Discovery + JWKS are cached for an hour, with auto-refresh on key miss (handles key rotation).
  • Supported signing algorithms: RS256/384/512, ES256/384/512, PS256/384/512.
  • Provider configuration is in-memory only — re-register on every server start (call Openid::register from init()).