In this page
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 aruntime::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.shfor agroupsmapper doing exactly this), and readclaims.groups; or - read
r.access_tokenin 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;roleapplies 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;falsewhen 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 fromclaims.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.nullif not registered. Never includes theclient_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 thereturn_tofrom the originatinglogin().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::registerfrominit()).