8.4.633-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 GreyCat’s embedded 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
    // (see "How default_match signs a user in" below)
    // Openid::set_hook(my_resolver);
}

client_id is a String and System::getEnv returns String?, hence the !!. When the variable is missing, main() throws there and the provider is not registered. Providers live in memory, so main() registers them again on every server start.

How default_match signs a user in

  1. A subject (claims.sub, the provider’s permanent id of the user) that signed in before signs in as the same Identity, whatever its preferred_username and email say now. A provider that changes the case of a username (Alice@Corp.com one day, alice@corp.com the next) or renames a user keeps them on their Identity.
  2. Otherwise claims.preferred_username, then claims.email (skipped when the provider sends email_verified: false), is looked up with runtime::Identity::get_by_name, which ignores case and Unicode normalization. With auto_create_identity: true an unknown name is created with role "user"; otherwise, or when the runtime refuses the name, the login is rejected.
  3. The Identity found is bound to the subject. A login whose name matches an Identity already bound to another subject of the same provider is rejected: it is another account at the provider claiming the same name. When a user’s account was recreated at the provider, hand the Identity over with OpenidHelpers::unbind(provider_id, identity).

Microsoft Entra ID sends no email_verified, and a multi-tenant app can receive any email from another tenant: match Entra users on preferred_username, or check claims.raw in a hook.

On a provider anyone can sign in with (public Google, multi-tenant Entra ID), auto_create_identity lets a stranger take a name first, after which its owner is rejected. Name the Identities with stable_name in a hook instead:

fn open_provider_hook(provider_id: String, r: OidcResult): Identity? {
    var identity = OpenidHelpers::bound(provider_id, r.claims.sub)
        ?? OpenidHelpers::ensure(OpenidHelpers::stable_name(provider_id, r.claims), "user");
    if (identity == null || !OpenidHelpers::bind(provider_id, r.claims.sub, identity)) {
        return null;
    }
    return identity;
}

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(...) as in the quick start
    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 (for groups, a “Group Membership” mapper with claim name groups and “Full group path” off), 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.
  • bound(provider_id, sub): Identity? — the Identity a subject signs in as, once bound.
  • bind(provider_id, sub, identity): bool — bind a subject to an Identity, as default_match does; false when the Identity is bound to another subject of that provider. Call it from a hook to get the same protection.
  • unbind(provider_id, identity) — release an Identity from its subject.
  • 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 gc.runtime.Identity.logout();                      // clears the cookie
const url = await gc.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)

This library ships no browser code. The browser side lives in the web SDK, @greycat/web, as gc.sdk.OpenidClient. 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.

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.

The same SDK drives the redirect flow from a single page: gc.sdk.init({ auth: { openid: "keycloak" } }) sends the browser to Openid::login, and finishes with Openid::callback when it comes back.

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. Register every provider again on each server start by calling Openid::register from main().