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 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 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
// (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
- A subject (
claims.sub, the provider’s permanent id of the user) that signed in before signs in as the same Identity, whatever itspreferred_usernameandemailsay now. A provider that changes the case of a username (Alice@Corp.comone day,alice@corp.comthe next) or renames a user keeps them on their Identity. - Otherwise
claims.preferred_username, thenclaims.email(skipped when the provider sendsemail_verified: false), is looked up withruntime::Identity::get_by_name, which ignores case and Unicode normalization. Withauto_create_identity: truean unknown name is created with role"user"; otherwise, or when the runtime refuses the name, the login is rejected. - 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
groupsand “Full group path” off), 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.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, asdefault_matchdoes;falsewhen 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;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 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.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)
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::registerfrommain().