Authentication
The OAuth issuers a server accepts tokens from, and what it checks on every token.
OAuth is the only way to call a server: there are no API keys and no passwords. The
issuers of the config list the identity providers whose tokens are accepted, at least one.
issuers:
- issuer: https://login.example.com
audience: hammerkit
groupsClaim: groups # default
clientId: hammerkit-cli
scope: openid api://hammerkit/.default
- issuer: https://token.actions.githubusercontent.com # CI jobs, matched by claims
audience: hammerkit| Key | What it does |
|---|---|
issuer | The issuer, written exactly as the iss of its tokens (some providers end it with a slash). An https URL, http only for localhost. Its keys are fetched from its OpenID discovery document. |
audience | What the aud of a token has to contain: an API audience registered for the server. |
groupsClaim | The claim that lists the groups of a caller, groups by default. Entitlements with group match on it. |
clientId | The public OAuth client that remote login signs in with, by the device authorization grant. An issuer without one only takes tokens obtained elsewhere, such as a CI job's or an agent's own. |
scope | What remote login asks for, openid by default. |
The server publishes the issuers with their client, audience and scope in its
discovery document, so remote login needs nothing
configured beyond remote add.
What is checked
Every call except discovery and the health check carries an OAuth access token as
Authorization: Bearer <token>. The server fetches the issuer's keys from its
OpenID discovery document and accepts a token only when
- it is signed with
RS256,RS384,RS512,PS256,ES256,ES384orES512(nevernoneor a shared secret), - its
issis one of the configured issuers and itsaudcontains the configured audience, - it has not expired and is already valid (a minute of clock skew is allowed), and
- it has a
sub.
Keys are cached for ten minutes, and fetched again, at most once a minute, when a
token names a key the server does not know yet, so a rotation needs no restart. A
refused token only gets 401 invalid token; the reason goes to the server's log,
with everything a caller sent quoted and stripped of control characters, so a caller
can't forge log lines. Concurrent requests share one key fetch, a failing issuer is
left alone for ten seconds, and the previous keys keep working for up to an hour
while the issuer is down.
Two things the server does not check, so set them up on the identity provider:
audhas to be an API audience for the server, not the OAuth client's own id, or an ID token for that client would be accepted. The server does not look at the token type (typ) or the authorized party (azp).- A
claimsentitlement matches any string claim as it finds it. It does not know thatemailneedsemail_verified; match onsub, a group or a claim the provider controls.
Identity providers
Any OpenID Connect provider works when it issues JWT access tokens for an API audience
of the server and publishes /.well-known/openid-configuration below the issuer, with an
issuer that is exactly the configured one. remote login also needs the provider's
device authorization endpoint and a public client. Some providers only hand out JWTs to
workloads (CI jobs, service accounts) and opaque tokens to people; those are listed
without clientId, and people sign in through another issuer of the same server.
| Provider | People (remote login) | Agents and CI |
|---|---|---|
| Keycloak | yes | clients with a service account |
| Microsoft Entra ID | yes | app registrations (client credentials) |
| no, access tokens are opaque | identity tokens of service accounts | |
| GitHub | no, access tokens are opaque | GitHub Actions OIDC tokens |
| GitLab | no, access tokens are opaque | GitLab CI ID tokens |
To let people with a GitHub or GitLab account sign in, broker that login through Keycloak or Entra ID; the server then sees the broker's tokens.
Keycloak
issuers:
- issuer: https://keycloak.example.com/realms/corp
audience: hammerkit
clientId: hammerkit-cli
entitlements:
- group: { iss: https://keycloak.example.com/realms/corp, name: platform }
builds: publish- Create a public client
hammerkit-cliwith OAuth 2.0 Device Authorization Grant enabled. - Keycloak does not put an API audience into access tokens by default. Add a client
scope with an Audience mapper that includes the custom audience
hammerkitin the access token, and assign it tohammerkit-cliand to the agents' clients. - Add a Group Membership mapper with the token claim name
groupsand Full group path off, so groups arrive asplatformand not/platform. - An agent is a confidential client with Service accounts roles on; it gets its token
with the client credentials grant. Its
subis the id of the client's service account user, which asubjectentitlement names, or put the service account user in a group.
Microsoft Entra ID
issuers:
- issuer: https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0 # your tenant id
audience: 11111111-1111-1111-1111-111111111111 # client id of the API app
clientId: 22222222-2222-2222-2222-222222222222 # client id of the CLI app
scope: api://hammerkit/access
groupsClaim: roles
entitlements:
- group: { iss: https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0, name: platform }
builds: publish- Register an API app: under Expose an API set the Application ID URI
api://hammerkitand add a scopeaccess. SetrequestedAccessTokenVersionto2in its manifest: version 2 tokens carry the issuer above and the API's client id asaud. Version 1 tokens are issued byhttps://sts.windows.net/<tenant id>/instead. - Register a CLI app with Allow public client flows on and the API permission
access; its client id isclientId. - Use the tenant id in the issuer, not
commonororganizations: those have no single issuer. - The
groupsclaim of Entra holds object ids, not names. Define app roles on the API app (such asplatform), assign them to groups, users or agent apps, and read them withgroupsClaim: roles. - An agent is an app registration that gets
api://hammerkit/.defaultwith the client credentials grant. Itssubis the object id of its service principal, and the app roles assigned to it are inrolestoo.
issuers:
- issuer: https://accounts.google.com
audience: hammerkit
entitlements:
- subject: { iss: https://accounts.google.com, sub: '104857382910563728194' } # unique id of the service account
builds: runGoogle's access tokens are opaque, so a Google issuer takes the identity tokens of
service accounts, whose aud is whatever the caller asks for:
# a workload on GCE, GKE or Cloud Run
curl -s -H 'Metadata-Flavor: Google' \
'http://metadata.google.internal/computeMetadata/v1/instance/service-account/identity?audience=hammerkit'
# elsewhere, impersonating the service account
gcloud auth print-identity-token --impersonate-service-account=agent@corp-ci.iam.gserviceaccount.com --audiences=hammerkitThe sub of such a token is the unique id of the service account, shown by
gcloud iam service-accounts describe. There is no groups claim, so entitlements name
each subject.
GitHub Actions
issuers:
- issuer: https://token.actions.githubusercontent.com
audience: hammerkit
entitlements:
- claims:
iss: https://token.actions.githubusercontent.com
match: { repository: corp/payments, ref: refs/heads/main }
builds: runA job with permissions: { id-token: write } asks for a token with the audience of the
server:
curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=hammerkit" | jq -r .valueMatch on repository, ref, environment or job_workflow_ref. repository_id and
repository_owner_id do not change when a repository is renamed or recreated under the
same name, so they are the safer choice for a long-lived entitlement.
GitLab CI
issuers:
- issuer: https://gitlab.com # or the address of your GitLab
audience: hammerkit
entitlements:
- claims:
iss: https://gitlab.com
match: { project_path: corp/payments, ref: main, ref_type: branch, ref_protected: 'true' }
builds: runA job declares the token it needs, and GitLab puts it in the variable:
deploy:
id_tokens:
HAMMERKIT_TOKEN:
aud: hammerkit
script:
- curl -H "Authorization: Bearer $HAMMERKIT_TOKEN" https://build.example.com/v1/meref_protected is the string 'true' on a protected branch or tag, so only pipelines
that maintainers control match. project_id stays the same when a project is renamed.
Transport
Plain http carries OAuth tokens, so listen on anything but the loopback interface needs
tls (a certificate and key) or behindTlsProxy: true when a proxy in front terminates
TLS. A config without either is refused at startup.
listen: { host: 0.0.0.0, port: 8480 }
tls:
cert: /etc/hammerkit/tls.crt
key: /etc/hammerkit/tls.key