Reference
Every field, flag, path and object name in one place.
OAuthClient spec
Namespaced; short name oac. The namespace is the owning team.
| Field | Type | Required | Rules |
|---|---|---|---|
clientType | string | yes | public or confidential |
grantTypes | list | yes | At least one of authorization_code, refresh_token, client_credentials. client_credentials requires confidential. |
redirectUris | list | with authorization_code | Absolute https URLs; no wildcards or fragments. http only for localhost, 127.0.0.1 or ::1 on public clients. |
scopes | list | no | Defaults to [openid]. Each scope must map to an IdP scope mapping. |
allowGroups | list | no | IdP groups allowed to sign in through the client. Defaults to the owner group. |
ownerGroup | string | no | Must equal the namespace; empty means the namespace. |
displayName | string | no | Shown in the IdP. Defaults to <namespace>/<name>. |
rotation.maxAgeDays | integer | no | Confidential clients only. The controller issues a new secret once the current one is this many days old, measured from lastSecretIssued. |
Annotations
| Annotation | Value | Effect | Approval |
|---|---|---|---|
courier.sororlab.dev/rotate | Any string, for example a date or ticket number | Issues a new secret once per distinct value; the value is copied to status.rotationHandled. Confidential clients only. | None beyond the usual review |
courier.sororlab.dev/adopt | Slug of an existing IdP application | Takes over that application instead of creating <team>-<name>: marks it managed and owned by the team, keeps the client ID, issues a new secret. The vault path uses the adopted name. | identity-approved label (policy annotationApprovals) |
An application already owned by another team is never adopted; the request stays NameConflict.
Status
| Field | Meaning |
|---|---|
conditions[type=Ready] | True when the client exists and credentials are active |
clientId | Client ID in the IdP (not sensitive) |
secretPath | Vault path where the owning team reads the credentials |
credentialsDelivered | Credentials were stored and promoted to active. If lost, the controller issues a new secret. |
lastSecretIssued | When Courier last generated a secret for this client; the clock for rotation.maxAgeDays |
rotationHandled | The last rotate annotation value acted on. A different value triggers one more rotation. |
identityProvider | Adapter instance managing the client |
observedGeneration | Spec generation last reconciled |
kubectl get oauthclients shows columns READY, TYPE, CLIENT ID, SECRET PATH, AGE.
Condition reasons
| Reason | Ready | Meaning | Retried |
|---|---|---|---|
Delivered | True | Client exists; credentials are active at secretPath | Re-applied every resync period |
InvalidSpec | False | The request breaks a rule; nothing was created | No; waits for a spec change |
NameConflict | False | An IdP application with this name exists and is not managed by Courier, or is owned by another team. Add the adopt annotation to take it over. | No |
ReconcileFailed | False | IdP or vault call failed; the message has details | Yes, with back-off |
DeleteFailed | False | Cleanup failed during deletion; the object stays until it succeeds | Yes, with back-off |
Naming rules
| Thing | Pattern | Example |
|---|---|---|
| Request file | requests/<team>/<name>.yaml | requests/team-alpha/billing-sync.yaml |
| Team directory, namespace, IdP owner group | policy teamPattern | team-alpha |
| Client name in the IdP | <team>-<name>, a DNS label of at most 63 characters | team-alpha-billing-sync |
| Authentik provider | courier-<team>-<name> | courier-team-alpha-billing-sync |
| Vault path | kv/teams/<team>/oauth-clients/<team>-<name> | kv/teams/team-alpha/oauth-clients/team-alpha-billing-sync |
| ArgoCD application | courier-requests-<team> | courier-requests-team-alpha |
Policy file
requests/.policy.yaml, owned by the identity team.
teamPattern: "^team-[a-z0-9-]+$" # regex every team directory must match
allowedRedirectHosts: # exact hosts, or "*.domain" for subdomains; empty allows any
- "*.sororlab.dev"
- localhost
- 127.0.0.1
grantApprovals: # grant → label required on the PR (changed requests only)
client_credentials: security-approved
annotationApprovals: # annotation → label required on the PR
courier.sororlab.dev/adopt: identity-approved
Unknown keys are rejected.
courier validate
go run ./cmd/courier validate [flags] [requests-dir]
| Flag | Default | Purpose |
|---|---|---|
requests-dir (argument) | requests | Directory to validate |
--policy | <dir>/.policy.yaml if present | Policy file |
--changed | none | Comma-separated files changed by the pull request; drives the summary and approval checks |
--labels | none | Comma-separated pull request labels |
--enforce-approvals | false | Require policy approval labels on changed requests |
--summary | none | Append a Markdown summary to this file (the workflow uses $GITHUB_STEP_SUMMARY) |
--vault-kv-mount | kv | Mount shown in summary paths |
Exit codes: 0 valid, 1 problems found, 2 usage or load error. In GitHub Actions each problem is also printed as a file annotation.
Rules applied
- Files at
<dir>/<team>/<name>.yaml; hidden files and non-YAML files are ignored - Only
courier.sororlab.dev/v1alpha1OAuthClientobjects; unknown fields rejected metadata.namespaceequals the team directory;metadata.nameequals the file name; no duplicates- The same client rules the controller enforces (owner group, grants, redirects, name length)
- Policy: team pattern, redirect hosts, approval labels
Controller settings
| Flag | Environment | Default | Purpose |
|---|---|---|---|
--authentik-url | AUTHENTIK_URL | required | Authentik base URL |
| – | AUTHENTIK_TOKEN | required | Controller's API token (from Secret courier-authentik) |
--authentik-groups-mapping | AUTHENTIK_GROUPS_MAPPING | oauth-groups | Scope mapping that emits the groups claim |
--idp-name | COURIER_IDP_NAME | authentik | Adapter instance name, shown in status and the vault entry |
--vault-addr | VAULT_ADDR | required | Vault address |
--vault-kv-mount | VAULT_KV_MOUNT | kv | KV v2 mount for credentials |
--vault-auth-mount | VAULT_AUTH_MOUNT | kubernetes | Kubernetes auth mount |
--vault-role | VAULT_ROLE | courier | Kubernetes auth role |
| – | VAULT_TOKEN | unset | Static token for development; overrides Kubernetes auth |
--resync-period | – | 10m | How often ready clients are re-applied |
Vault layout
kv/ # KV v2
└── teams/<team>/oauth-clients/<team>-<name>
client_id # always
client_secret # confidential clients only
client_type # public | confidential
grant_types # space-separated
scopes # space-separated
issuer
authorization_endpoint
token_endpoint
jwks_uri
userinfo_endpoint
owner_group
idp # adapter instance
managed_by # courier-controller
state # pending | active
Auth mounts: oidc/ (role human), jwt/ (role machine), kubernetes/ (role courier). Audit device file/ at /vault/audit/audit.log.
Vault policies
team-<name>
path "kv/data/teams/team-alpha/*" {
capabilities = ["read"]
}
path "kv/metadata/teams/team-alpha/*" {
capabilities = ["read", "list"]
}
path "kv/metadata/teams/team-alpha" {
capabilities = ["list"]
}
courier
# write, never read
path "kv/data/teams/+/oauth-clients/*" {
capabilities = ["create", "update", "patch"]
}
path "kv/metadata/teams/+/oauth-clients/*" {
capabilities = ["delete"]
}
Identity groups: for every IdP group G, external group G (alias G on oidc/) and G-jwt (alias G on jwt/), both with policy G.
Objects Courier creates in the IdP
| Object | Values |
|---|---|
| OAuth2 provider | Name courier-<team>-<name>; client_type, grant_types, strict redirect_uris; scope mappings; sub_mode: user_email; claims included in ID token; signing key authentik Self-signed Certificate |
| Application | Slug <team>-<name>; meta_description starts with managed-by: courier; policy engine mode any |
| Policy bindings | One group binding per allowGroups entry; bindings for groups no longer listed are removed |
Built-in scopes map to Authentik's managed mappings (openid, email, profile, offline_access). Other scopes need an entry in the adapter's scope mapping configuration, for example groups → oauth-groups.
ArgoCD objects
| Object | Key settings |
|---|---|
AppProject courier-requests | Source: this repository. Destinations: team-*. Namespaced kinds: courier.sororlab.dev/OAuthClient. Cluster kinds: Namespace. |
ApplicationSet courier-requests | Git directory generator on requests/*; includes *.yaml; automated sync with prune and self-heal; CreateNamespace=true |
Application courier-controller | Path deploy/phase1/controller; namespace courier-system; server-side apply |
AppProject courier-team-access | Source: this repository. Destinations: team-*. Namespaced kinds: ServiceAccount, external-secrets.io/SecretStore. Cluster kinds: Namespace. |
ApplicationSet courier-team-access | Git directory generator on requests/*; renders chart deploy/team-access with team=<dir>; automated sync with prune and self-heal |
Team access
| Object | Where | Settings |
|---|---|---|
ServiceAccount courier-secrets | each team-* namespace | automountServiceAccountToken: false |
SecretStore courier-vault | each team-* namespace | Vault provider, server http://vault-active.vault.svc:8200, path kv, version v2, Kubernetes auth mount kubernetes, role <team>, service account courier-secrets |
Vault role auth/kubernetes/role/<team> | Vault | Bound to <team>/courier-secrets; policy <team>; TTL 1h, max 4h |
Chart values (deploy/team-access/values.yaml): team (required), serviceAccountName, vault.server, vault.kvMount, vault.authMount.
ExternalSecret key for a request: teams/<team>/oauth-clients/<team>-<name> (without the kv/ mount). With dataFrom.extract, the Secret has one key per vault field.
Python sample API
Module samples/python/courier_credentials.py, requires hvac==2.4.0.
| Name | Signature | Behavior |
|---|---|---|
vault_client | (addr=None, *, method=None, role=None, mount=None, jwt=None, namespace=None) → hvac.Client | Signs in with token, jwt or kubernetes; raises PermissionError if not authenticated |
read_credentials | (client, team, name, *, kv_mount="kv") → OAuthClientCredentials | Reads the entry; raises PermissionError (forbidden), LookupError (missing), CredentialsNotReady (not active) |
client_credentials_token | (creds, *, scope=None, extra=None) → dict | POSTs to token_endpoint; extra adds provider-specific form fields |
OAuthClientCredentials | frozen dataclass | path, client_id, client_type, client_secret (hidden from repr), grant_types, scopes, issuer, token_endpoint, authorization_endpoint, jwks_uri, userinfo_endpoint, owner_group |
Environment: VAULT_ADDR, COURIER_VAULT_AUTH, VAULT_TOKEN, COURIER_JWT, COURIER_JWT_FILE, COURIER_SA_TOKEN_FILE, VAULT_ROLE, VAULT_NAMESPACE.
Controller image
| Item | Value |
|---|---|
| Repository | ghcr.io/paimonsoror/courier-controller (public) |
| Tags | sha-<short commit> for every build; main for the latest |
| Built by | .github/workflows/controller-image.yml, linux/amd64 |
| Deployed | By digest from deploy/phase1/controller/kustomization.yaml; imagePullPolicy: IfNotPresent |
Backstage
Template courier-oauth-client
| Field | Required | Rules |
|---|---|---|
team | yes | Group entity with spec.type: courier-team |
name | yes | ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$, max 40 |
displayName | no | free text |
clientType | yes | confidential (default) or public |
grantTypes | yes | one or more of authorization_code, refresh_token, client_credentials |
redirectUris | no | list; required by the validator for authorization_code |
scopes | no | list, default [openid] |
purpose | yes | free text, copied into the pull request |
Steps: fetch:template into requests/<team>/, then publish:github:pull-request on branch request/<team>-<name>. Output links: the pull request and the request guide.
Client entity (<name>.catalog.yml)
apiVersion: backstage.io/v1alpha1
kind: Resource
metadata:
name: <team>-<name>
annotations:
backstage.io/kubernetes-namespace: <team>
backstage.io/kubernetes-label-selector: courier.sororlab.dev/client=<name>
courier.sororlab.dev/secret-path: kv/teams/<team>/oauth-clients/<team>-<name>
spec:
type: oauth-client
owner: group:default/<team>
The matching request must carry metadata.labels["courier.sororlab.dev/client"]: <name>.
Groups
backstage/catalog/teams.yaml: one Group per team, name equal to the IdP group and namespace, spec.type: courier-team, parent sororlab.