OIDC (OpenID Connect) library for Modula-2. Provides RS256 JWT verification with JWKS key management and Keycloak-style claim extraction. Designed for server-side token validation against external identity providers (Keycloak, Okta, Auth0).
| Module | Purpose |
|---|---|
Oidc.def/mod |
OIDC provider, discovery parsing, JWT RS256 verification, claim extraction. |
Jwks.def/mod |
JWKS key set management — parse JWKS JSON, store RSA keys by kid, RS256 signature verification. Thread-safe (mutex-protected). |
OidcBridge.def + oidc_bridge.c |
Thin C FFI to OpenSSL for RSA key construction and verification. |
| Library | Used for |
|---|---|
m2auth |
m2_auth_b64url_decode, m2_auth_get_unix_time |
m2json |
SAX JSON parser for JWKS and JWT payload parsing |
m2pthreads |
Mutex for thread-safe KeySet access |
| OpenSSL | RSA public key construction (EVP_PKEY) and signature verification |
manifest_version = 1
name = m2oidc
version = 0.1.0
edition = pim4
entry = src/Oidc.mod
includes = src
[deps]
m2auth = "path:../m2auth"
m2json = "path:../m2json"
m2pthreads = "path:../m2pthreads"
[cc]
extra-c = src/oidc_bridge.c
cflags = -I/opt/homebrew/opt/openssl@3/include
ldflags = -L/opt/homebrew/opt/openssl@3/lib
libs = -lssl -lcryptoStatus = (OcOk, OcInvalid, OcBadSignature, OcExpired, OcNotYetValid,
OcBadIssuer, OcBadAudience, OcNoKid, OcKeyNotFound,
OcParseFailed, OcUnsupportedAlg, OcOutOfMemory);
OidcClaims = RECORD
subject: ARRAY [0..127] OF CHAR;
issuer: ARRAY [0..255] OF CHAR;
audience: ARRAY [0..127] OF CHAR;
username: ARRAY [0..127] OF CHAR; (* preferred_username *)
email: ARRAY [0..255] OF CHAR;
azp: ARRAY [0..127] OF CHAR; (* authorized party *)
expUnix: LONGINT;
nbfUnix: LONGINT;
iatUnix: LONGINT;
roles: ARRAY [0..15] OF ARRAY [0..63] OF CHAR;
roleCount: CARDINAL;
groups: ARRAY [0..15] OF ARRAY [0..127] OF CHAR;
groupCount: CARDINAL;
END;
Populated by VerifyToken. Standard JWT claims (sub, iss, aud, exp,
nbf, iat) plus Keycloak extensions (preferred_username, email, azp,
realm_access.roles, groups).
PROCEDURE CreateProvider(VAR prov: Provider;
VAR issuer, clientId: ARRAY OF CHAR;
clockSkewSecs: CARDINAL;
keySet: ADDRESS): Status;
PROCEDURE DestroyProvider(VAR prov: Provider): Status;
Create a provider bound to a specific issuer URL and expected audience
(client ID). keySet is a Jwks.KeySet handle. clockSkewSecs is
the tolerance for exp/nbf validation (typically 30s).
DestroyProvider frees the provider but does NOT destroy the KeySet — the caller owns that lifecycle.
PROCEDURE ParseDiscovery(json: ADDRESS; jsonLen: CARDINAL;
VAR issuerOut, jwksUriOut: ARRAY OF CHAR): Status;
Parse an OIDC discovery document (the JSON from
/.well-known/openid-configuration). Extracts the issuer and
jwks_uri fields. Standalone — does not require a Provider.
PROCEDURE VerifyToken(prov: Provider;
VAR token: ARRAY OF CHAR;
VAR claims: OidcClaims): Status;
Full RS256 JWT verification and claim extraction:
- Split token at dots into header, payload, signature.
- Base64url-decode header, parse for
alg(must be RS256) andkid. - Look up kid in the provider's KeySet via
Jwks.FindKey. - Verify signature via
Jwks.VerifyRS256— signing input is the raw base64url textheader.payload(NOT decoded bytes). - Base64url-decode payload, parse claims:
- Standard: sub, iss, aud (string or array), exp, nbf, iat, azp.
- Keycloak:
realm_accessobject →rolesarray,groupsarray. - Username:
preferred_username. Email:email. - SPECTRA:
role(single string, added to roles array).
- Validate: issuer matches provider, audience matches clientId, exp/nbf with clock skew tolerance.
Returns OcOk on success. Specific error codes for each failure mode
allow callers to return appropriate HTTP error messages.
PROCEDURE PeekAlg(VAR token: ARRAY OF CHAR;
VAR algOut: ARRAY OF CHAR): Status;
Peek at the alg field in a JWT header without full verification.
Used by Dispatch.mod to route between the HS256 and RS256
verification paths.
PROCEDURE GetKeySet(prov: Provider): ADDRESS;
Returns the Jwks.KeySet handle associated with a provider. Used for
JWKS refresh — fetch new JWKS JSON and call Jwks.ParseJson on the
existing KeySet to atomically replace all keys.
Thread-safe JWKS key set management. All operations acquire an internal mutex.
Status = (JkOk, JkInvalid, JkNoSuchKid, JkFull,
JkParseFailed, JkOutOfMemory);
MaxKeys = 16; (* maximum RSA keys in one set *)
MaxKidLen = 63; (* maximum kid string length *)
PROCEDURE Create(VAR ks: KeySet): Status;
PROCEDURE Destroy(VAR ks: KeySet): Status;
Create an empty key set. Destroy frees all RSA key handles (via OpenSSL) and the key set itself.
PROCEDURE ParseJson(ks: KeySet;
json: ADDRESS; jsonLen: CARDINAL): Status;
Parse a JWKS JSON document and replace all keys in the set. Navigates
to the "keys" array, extracts each JWK object with kty=RSA and
alg=RS256 (or absent alg), base64url-decodes n and e, constructs
RSA public key handles via OidcBridge.m2_oidc_rsa_from_ne.
Clears existing keys before adding new ones (under lock). Keys without
a kid field are skipped. Non-RSA and non-RS256 keys are skipped.
Safe to call repeatedly for key refresh — existing keys are freed and replaced atomically (under mutex).
PROCEDURE FindKey(ks: KeySet;
VAR kid: ARRAY OF CHAR): Status;
Check whether an RSA key with the given kid exists. Returns JkOk
if found, JkNoSuchKid if not. Existence check only — does not
return the key handle.
PROCEDURE Count(ks: KeySet): CARDINAL;
Return the number of keys currently loaded.
PROCEDURE VerifyRS256(ks: KeySet;
VAR kid: ARRAY OF CHAR;
msg: ADDRESS; msgLen: CARDINAL;
sig: ADDRESS; sigLen: CARDINAL): Status;
Verify an RS256 (RSASSA-PKCS1-v1_5 + SHA-256) signature using the key
identified by kid. Does its own kid lookup internally.
msg/msgLen: the signing input (raw base64url textheader.payload).sig/sigLen: the raw decoded signature bytes.- Returns
JkOkif valid,JkNoSuchKidif kid not found,JkInvalidif signature verification fails.
Three C functions wrapping OpenSSL. Uses OSSL_PARAM_BLD +
EVP_PKEY_fromdata on OpenSSL >= 3.0, falls back to RSA_new +
RSA_set0_key on older versions.
DEFINITION MODULE FOR "C" OidcBridge;
FROM SYSTEM IMPORT ADDRESS;
(* Construct RSA public key from raw big-endian n + e bytes.
Returns opaque EVP_PKEY* handle, or NIL on failure. *)
PROCEDURE m2_oidc_rsa_from_ne(n: ADDRESS; nLen: INTEGER;
e: ADDRESS; eLen: INTEGER): ADDRESS;
(* Verify RS256 signature. Returns 0 if valid, -1 if invalid. *)
PROCEDURE m2_oidc_rsa_verify(key: ADDRESS;
msg: ADDRESS; msgLen: INTEGER;
sig: ADDRESS; sigLen: INTEGER): INTEGER;
(* Free an RSA key handle (EVP_PKEY_free). *)
PROCEDURE m2_oidc_rsa_free(key: ADDRESS);
END OidcBridge.
The caller is responsible for fetching the OIDC discovery and JWKS
JSON documents. SPECTRA uses OidcFetch.mod which wraps m2http's
HTTPClient.Get with a temporary EventLoop for a blocking HTTPS
request. Any HTTP client will work — the library only needs the raw
JSON bytes.
Startup:
1. Jwks.Create(keySet)
2. HTTP GET issuer/.well-known/openid-configuration
3. Oidc.ParseDiscovery(json, len, issuerOut, jwksUriOut)
4. HTTP GET jwksUri
5. Jwks.ParseJson(keySet, jwksJson, jwksLen)
6. Oidc.CreateProvider(prov, issuer, clientId, 30, keySet)
7. Register JWKS refresh timer
Per request:
1. Oidc.PeekAlg(token, algBuf)
2. If algBuf = "RS256":
Oidc.VerifyToken(prov, token, claims) → map roles
3. Else:
Auth.VerifyBearerToken (HS256 path)
JWKS refresh (timer callback):
1. Fetch JWKS JSON
2. ks := Oidc.GetKeySet(prov)
3. Jwks.ParseJson(ks, json, len) (* atomic replace under lock *)
Shutdown:
1. Oidc.DestroyProvider(prov)
2. Jwks.Destroy(keySet)
Keycloak tokens carry roles in realm_access.roles:
{
"realm_access": {
"roles": ["admin", "uma_authorization"]
},
"preferred_username": "matt",
"email": "matt@example.com"
}After VerifyToken, scan claims.roles[0..roleCount-1] for
application-specific roles (e.g. admin, operator, annotator, viewer).
- Maximum keys per KeySet: 16
- Maximum kid length: 63 characters
- Maximum RSA modulus: 4096-bit (512 bytes decoded n)
- Maximum token size: limited by caller's buffer (VAR token: ARRAY OF CHAR)
- Maximum payload: 8192 bytes decoded
- Maximum roles: 16
- Maximum groups: 16
- Only RS256 algorithm supported (no RS384, RS512, ES256, EdDSA)
- JWKS must contain
"keys"array at the top level - Each JWK must have a
kidfield to be usable
Assignment to a VAR key: ADDRESS out parameter generates incorrect C
code (memcpy instead of direct pointer assignment). The FindKey
procedure was simplified to return only a status (no key output) as a
workaround. VerifyRS256 does its own internal kid lookup and does not
expose the raw key handle.
See mx-pitfalls.md item 9 for details.