Embedded IAM

Federate IdPs in cladone-auth, map claims and assign user grants.

This example runs the optional embedded IAM cladone-auth as the single OIDC issuer for Gate One. cladone-auth federates one or more upstream IdPs (Google, Microsoft Entra, Auth0, any OIDC issuer — each keyed under upstreams) and normalizes their claims into the roles/scopes/claims Gate One enforces.

Embedded IAM configuration

{
  "publicUrl": "https://auth.example.com",
  "listen": ":8081",
  "redis": {
    "url": "redis://redis:6379/0",
    "passwordEnv": "REDIS_PASSWORD",
    "prefix": "cladone:iam:v1:",
    "authPrefix": "cladone:auth:v1:"
  },
  "tenants": [{
    "id": "company",
    "clientId": "gate-one",
    "clientSecretEnv": "MYCOMPANY_IAM_CLIENT_SECRET",
    "redirectUris": ["https://login.example.com/auth/callback"],
    "postLogoutRedirectUris": ["https://app.example.com/"],
    "upstreams": {
      "google": {
        "issuer": "https://accounts.google.com",
        "clientIdEnv": "GOOGLE_CLIENT_ID",
        "clientSecretEnv": "GOOGLE_CLIENT_SECRET",
        "hostedDomain": "example.com"
      },
      "microsoft": {
        "issuer": "https://login.microsoftonline.com/common/v2.0",
        "clientIdEnv": "MICROSOFT_CLIENT_ID",
        "clientSecretEnv": "MICROSOFT_CLIENT_SECRET",
        "optional": true
      }
    },
    "defaultRoles": ["user"],
    "allowedDomains": ["example.com"],
    "mappings": [
      { "claim": "groups", "values": ["admins"], "roles": ["admin"] }
    ],
    "rules": [
      {
        "when": [{ "claim": "email", "match": "domain", "values": ["example.com"] }],
        "then": {
          "roles": ["employee"],
          "copyClaims": [
            { "from": "email", "to": "email_domain", "transforms": ["keep:@:-1", "lower"] }
          ]
        }
      },
      {
        "when": [{ "claim": "groups", "match": "glob", "values": ["team-*"] }],
        "then": {
          "rolesFrom": [
            { "claim": "groups", "transforms": ["keep:-:-1", "lower"] }
          ]
        }
      }
    ]
  }]
}

upstreams is a map keyed by IdP id; each key is the value of the idp authorize parameter and the label on the built-in IdP chooser. An optional upstream is skipped (not shown, not validated) until its credentials exist, so you can ship it ahead of the secrets.

mappings translates IdP-specific claims into the common model (the legacy groupRoles block is still accepted for the groups claim). rules is the richer when/then policy — see below.

Gate One configuration

{
  "authProviders": [{
    "id": "company",
    "type": "oidc",
    "issuer": "https://auth.example.com/t/company",
    "clientId": "gate-one",
    "clientSecretEnv": "MYCOMPANY_OIDC_SECRET",
    "loginBaseUrl": "https://login.example.com",
    "cookieDomain": "example.com",
    "roleClaims": ["roles"],
    "sessionKeyEnv": "MYCOMPANY_SESSION_KEY",
    "sessionStore": {
      "type": "redis",
      "url": "redis://redis:6379/0",
      "passwordEnv": "REDIS_PASSWORD",
      "prefix": "cladone:auth:v1:"
    }
  }],
  "defaultAuthProvider": "company",
  "routes": [{
    "host": "app.example.com", "path": "/*", "frontend": true,
    "backend": "http://app:8080", "auth": { "provider": "company" }
  }]
}

The Redis store is here because this example is also the multi-instance form; a single IAM instance can use its memory store, and a single Gate One instance can use the default bbolt store.

The tenant’s postLogoutRedirectUris lists where a logout may return. List every application origin (here app.example.com); an empty list falls back to redirectUris, which covers only the login host, so a logout from an application host would be refused. See Logout and revocation.

Dynamic rights (rules)

Federated users are created at first login with no rights (JIT). Two additive mechanisms grant rights:

  • rules — evaluated on every login against the incoming token’s claims; the result is never persisted on the user. This is how you give a group of users (by email, domain, subject or IdP group) the right roles/scopes/claims automatically.

  • cladone-auth user set — a persisted per-user exception (below).

A rule reads the token (when) and writes the user (then):

{
  "when": [
    { "claim": "email",  "match": "domain", "values": ["example.com"] },
    { "claim": "groups", "values": ["support"] }
  ],
  "then": {
    "roles":      ["employee"],
    "scopes":     ["read:things"],
    "claims":     { "department": "support" },
    "rolesFrom":  [{ "claim": "groups", "transforms": ["keep:-:-1", "lower"] }],
    "copyClaims": [{ "from": "email", "to": "email_domain",
                     "transforms": ["keep:@:-1", "lower"] }]
  }
}
  • when — the test, evaluated against the incoming token. Each condition names a claim and either matches values (match: exact (default), prefix, suffix, domain, glob) or just requires the claim to be present. Several conditions are combined with AND.

  • then.roles / scopes / claims — constant grants: applied whenever the test passes, regardless of which value matched.

  • then.rolesFrom — derived roles: each value of the claim becomes a role.

  • then.copyClaims — derived claims: copy an incoming claim into the issued token, optionally renamed (to) and reshaped.

transforms is an ordered pipeline applied to every value: lower, upper, prefix:<p>, suffix:<p>, replace:<a>:<b>, and keep:<sep>:<from>[:<to>] — keep the fields from..to (negative counts from the end) joined by <sep>; a single index keeps one field. So keep:@:-1 is the email domain, keep:@:0 the local part, keep:@:0:-2 drops the last field (a@b@c → a@b), and keep:-:-1 turns team-support into support. Reserved claims (iss, sub, aud, roles, scope, email, …) can never be written.

Before the first login (pre-provisioning)

A federated user has no IAM record until the first login (JIT). To give rights to someone who has never signed in, put the condition in a rule: it is evaluated at each login, so the rights are already there the first time — without creating a user in advance.

By email

when.claim: "email" accepts one value or a list. With match: "exact" (the default), the list is effectively a group of emails:

"rules": [{
  "when": [{
    "claim": "email",
    "match": "exact",
    "values": ["alice@example.com", "bob@example.com"]
  }],
  "then": { "roles": ["support"], "scopes": ["read:things"] }
}]

When Alice or Bob signs in for the first time, the IAM creates the user and issues the support role and the read:things scope.

For a whole domain or team, use domain, suffix or glob:

"rules": [
  { "when": [{ "claim": "email", "match": "domain",
               "values": ["example.com"] }],
    "then": { "roles": ["employee"] } },
  { "when": [{ "claim": "email", "match": "glob",
               "values": ["*@team.example.com"] }],
    "then": { "roles": ["contractor"] } }
]

exact is case-sensitive; domain, prefix, suffix and glob are case-insensitive. Lowercase the addresses in the configuration (or use glob) if the IdP does not guarantee the case.

The cleanest "group → role" is the group managed by the IdP (Google Workspace, Entra, Keycloak…): the groups claim is mapped at login.

"mappings": [
  { "claim": "groups", "values": ["support"], "roles": ["support"] },
  { "claim": "groups", "values": ["finance"], "roles": ["finance"] }
]

or with the richer rule engine:

"rules": [{
  "when": [{ "claim": "groups", "values": ["support"] }],
  "then": { "roles": ["support"], "claims": { "department": "support" } }
}]

Membership is maintained in the IdP: removing the user from the group removes the role at the next login.

What is evaluated at login

first login
  -> JIT user (issuer, subject, email)
  -> defaultRoles
  -> mappings / legacy groupRoles (upstream claims)
  -> rules (email, domain, groups, sub, custom claims)
  -> persisted grants (user set; the user must already exist)
ApproachNamed group?Lifecycle

rules + email list

the list is the group

static; edit the config, reload

rules + domain/glob

no

static

IdP group (groups claim)

yes, managed in the IdP

automatic at the next login

AdminEmails

no (admin only)

static, case-insensitive email list

user set -issuer/-sub

no (persisted exception)

requires the user to exist

AdminEmails is the built-in email list that grants admin to those addresses; it is the only email-to-role shortcut outside the rules.

A rule result is never persisted on the user: it is recomputed at each login. Removing an address from the list (or the user from the IdP group) removes the role at the next login, but the JIT account remains with any persisted grants. To remove the account itself, use cladone-auth user delete once the user has signed in at least once.

user set needs an existing user (issuer/sub), so it cannot pre-provision a federated identity. For a local account, create it explicitly with its rights — it is a local identity, not the federated one:

cladone-auth user create -config embedded-iam.json -tenant company \
  -kind local -email alice@example.com -password '...' \
  -roles support

The email claim must be present in the upstream token: the default scopes include email. If the IdP does not expose it, use groups or sub as the rule condition instead.

Users and grants

The rules above cover the common case. For a one-off exception, assign roles/scopes/claims to a specific user (persisted):

# inspect the JIT user (issuer/subject come from the IdP token)
cladone-auth user list -config embedded-iam.json -tenant company
cladone-auth user show -config embedded-iam.json -tenant company \
  -issuer https://accounts.google.com -sub 'google-sub-1'

# assign common rights
cladone-auth user set -config embedded-iam.json -tenant company \
  -issuer https://accounts.google.com -sub 'google-sub-1' \
  -roles support -scopes read:things \
  -claims '{"department":"support"}'

Local users use the same commands with -kind local and a password. The same roles/scopes/claims model applies to both.

If the IdP subject changes unexpectedly, relink the same IAM user with -new-issuer/-new-sub instead of deleting it; its grants are preserved.

Run it

The packaged demo runs Gate One, cladone-auth, a local OIDC provider and Redis (Docker Compose):

cd gate-one-src/deploy/local/embedded-iam
BIN_DIR=~/gate-one-bin ./run.sh

The script prints the URLs and stops with ./run.sh down. See [deploy/local/embedded-iam](https://github.com/clad-one/gate-one/tree/main/deploy/local/embedded-iam) for the stack and its README.

To run it by hand instead:

# embedded IAM
export REDIS_PASSWORD=...
export MYCOMPANY_IAM_CLIENT_SECRET=...
export GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=...
cladone-auth -config embedded-iam.json

# gate one
export MYCOMPANY_OIDC_SECRET=...
export MYCOMPANY_SESSION_KEY="$(openssl rand -base64 32)"
gate-one -check -config routes.json
gate-one -config routes.json

What to observe

  • the IAM’s IdP chooser, then Google login;

  • the IAM session cookie on auth.example.com;

  • roleClaims: ["roles"] and the mapped admin role in the Gate One policy;

  • cladone-auth user show after first login, then a grant update that appears on the next login;

  • a global logout revoking sid and sub.

Note
  • The IAM is an issuer and user directory, not a per-resource policy engine; route rules stay in Gate One.

  • One external identity link per user; no account linking.

  • Upstream secrets stay in environment variables.

  • If you already run Keycloak/Zitadel, use them as the external IAM: Keycloak with Google.