Reference API courier.sororlab.dev/v1alpha1

Reference

Every field, flag, path and object name in one place.

OAuthClient spec

Namespaced; short name oac. The namespace is the owning team.

FieldTypeRequiredRules
clientTypestringyespublic or confidential
grantTypeslistyesAt least one of authorization_code, refresh_token, client_credentials. client_credentials requires confidential.
redirectUrislistwith authorization_codeAbsolute https URLs; no wildcards or fragments. http only for localhost, 127.0.0.1 or ::1 on public clients.
scopeslistnoDefaults to [openid]. Each scope must map to an IdP scope mapping.
allowGroupslistnoIdP groups allowed to sign in through the client. Defaults to the owner group.
ownerGroupstringnoMust equal the namespace; empty means the namespace.
displayNamestringnoShown in the IdP. Defaults to <namespace>/<name>.
rotation.maxAgeDaysintegernoConfidential clients only. The controller issues a new secret once the current one is this many days old, measured from lastSecretIssued.

Annotations

AnnotationValueEffectApproval
courier.sororlab.dev/rotateAny string, for example a date or ticket numberIssues 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/adoptSlug of an existing IdP applicationTakes 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

FieldMeaning
conditions[type=Ready]True when the client exists and credentials are active
clientIdClient ID in the IdP (not sensitive)
secretPathVault path where the owning team reads the credentials
credentialsDeliveredCredentials were stored and promoted to active. If lost, the controller issues a new secret.
lastSecretIssuedWhen Courier last generated a secret for this client; the clock for rotation.maxAgeDays
rotationHandledThe last rotate annotation value acted on. A different value triggers one more rotation.
identityProviderAdapter instance managing the client
observedGenerationSpec generation last reconciled

kubectl get oauthclients shows columns READY, TYPE, CLIENT ID, SECRET PATH, AGE.

Condition reasons

ReasonReadyMeaningRetried
DeliveredTrueClient exists; credentials are active at secretPathRe-applied every resync period
InvalidSpecFalseThe request breaks a rule; nothing was createdNo; waits for a spec change
NameConflictFalseAn 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
ReconcileFailedFalseIdP or vault call failed; the message has detailsYes, with back-off
DeleteFailedFalseCleanup failed during deletion; the object stays until it succeedsYes, with back-off

Naming rules

ThingPatternExample
Request filerequests/<team>/<name>.yamlrequests/team-alpha/billing-sync.yaml
Team directory, namespace, IdP owner grouppolicy teamPatternteam-alpha
Client name in the IdP<team>-<name>, a DNS label of at most 63 charactersteam-alpha-billing-sync
Authentik providercourier-<team>-<name>courier-team-alpha-billing-sync
Vault pathkv/teams/<team>/oauth-clients/<team>-<name>kv/teams/team-alpha/oauth-clients/team-alpha-billing-sync
ArgoCD applicationcourier-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]
FlagDefaultPurpose
requests-dir (argument)requestsDirectory to validate
--policy<dir>/.policy.yaml if presentPolicy file
--changednoneComma-separated files changed by the pull request; drives the summary and approval checks
--labelsnoneComma-separated pull request labels
--enforce-approvalsfalseRequire policy approval labels on changed requests
--summarynoneAppend a Markdown summary to this file (the workflow uses $GITHUB_STEP_SUMMARY)
--vault-kv-mountkvMount 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/v1alpha1 OAuthClient objects; unknown fields rejected
  • metadata.namespace equals the team directory; metadata.name equals 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

FlagEnvironmentDefaultPurpose
--authentik-urlAUTHENTIK_URLrequiredAuthentik base URL
AUTHENTIK_TOKENrequiredController's API token (from Secret courier-authentik)
--authentik-groups-mappingAUTHENTIK_GROUPS_MAPPINGoauth-groupsScope mapping that emits the groups claim
--idp-nameCOURIER_IDP_NAMEauthentikAdapter instance name, shown in status and the vault entry
--vault-addrVAULT_ADDRrequiredVault address
--vault-kv-mountVAULT_KV_MOUNTkvKV v2 mount for credentials
--vault-auth-mountVAULT_AUTH_MOUNTkubernetesKubernetes auth mount
--vault-roleVAULT_ROLEcourierKubernetes auth role
VAULT_TOKENunsetStatic token for development; overrides Kubernetes auth
--resync-period10mHow 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

ObjectValues
OAuth2 providerName 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
ApplicationSlug <team>-<name>; meta_description starts with managed-by: courier; policy engine mode any
Policy bindingsOne 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

ObjectKey settings
AppProject courier-requestsSource: this repository. Destinations: team-*. Namespaced kinds: courier.sororlab.dev/OAuthClient. Cluster kinds: Namespace.
ApplicationSet courier-requestsGit directory generator on requests/*; includes *.yaml; automated sync with prune and self-heal; CreateNamespace=true
Application courier-controllerPath deploy/phase1/controller; namespace courier-system; server-side apply
AppProject courier-team-accessSource: this repository. Destinations: team-*. Namespaced kinds: ServiceAccount, external-secrets.io/SecretStore. Cluster kinds: Namespace.
ApplicationSet courier-team-accessGit directory generator on requests/*; renders chart deploy/team-access with team=<dir>; automated sync with prune and self-heal

Team access

ObjectWhereSettings
ServiceAccount courier-secretseach team-* namespaceautomountServiceAccountToken: false
SecretStore courier-vaulteach team-* namespaceVault 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>VaultBound 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.

NameSignatureBehavior
vault_client(addr=None, *, method=None, role=None, mount=None, jwt=None, namespace=None) → hvac.ClientSigns in with token, jwt or kubernetes; raises PermissionError if not authenticated
read_credentials(client, team, name, *, kv_mount="kv") → OAuthClientCredentialsReads the entry; raises PermissionError (forbidden), LookupError (missing), CredentialsNotReady (not active)
client_credentials_token(creds, *, scope=None, extra=None) → dictPOSTs to token_endpoint; extra adds provider-specific form fields
OAuthClientCredentialsfrozen dataclasspath, 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

ItemValue
Repositoryghcr.io/paimonsoror/courier-controller (public)
Tagssha-<short commit> for every build; main for the latest
Built by.github/workflows/controller-image.yml, linux/amd64
DeployedBy digest from deploy/phase1/controller/kustomization.yaml; imagePullPolicy: IfNotPresent

Backstage

Template courier-oauth-client

FieldRequiredRules
teamyesGroup entity with spec.type: courier-team
nameyes^[a-z0-9]([-a-z0-9]*[a-z0-9])?$, max 40
displayNamenofree text
clientTypeyesconfidential (default) or public
grantTypesyesone or more of authorization_code, refresh_token, client_credentials
redirectUrisnolist; required by the validator for authorization_code
scopesnolist, default [openid]
purposeyesfree 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.