detached cc3aa02

feat(ci): OIDC ID tokens for CI jobs (#43)

commit
cc3aa02d949b4c9c48d85b4e03dd2ef68001c425
author
brice · · 2026-10-16 08:58 UTC
committer
bnema · · 2026-10-16 08:58 UTC
parent
e4fb10d
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
35 files · +2632 -23
M .hexcheck.yaml +3 -0
@@ -76,6 +76,9 @@ allow:
7676 - rule: no-adapter-to-adapter-imports
7777 path: internal/adapters/githubapi/**
7878 reason: rate-limits the clients of the REST subset with internal/clientlimit
79+ - rule: no-adapter-to-adapter-imports
80+ path: internal/adapters/oidchttp/**
81+ reason: rate-limits the clients of the OIDC token endpoint with internal/clientlimit
7982 - rule: no-adapter-to-adapter-imports
8083 path: internal/adapters/rpc/**
8184 reason: rate-limits API clients with internal/clientlimit
@@ -76,6 +76,9 @@ allow:
76 - rule: no-adapter-to-adapter-imports76 - rule: no-adapter-to-adapter-imports
77 path: internal/adapters/githubapi/**77 path: internal/adapters/githubapi/**
78 reason: rate-limits the clients of the REST subset with internal/clientlimit78 reason: rate-limits the clients of the REST subset with internal/clientlimit
79+ - rule: no-adapter-to-adapter-imports
80+ path: internal/adapters/oidchttp/**
81+ reason: rate-limits the clients of the OIDC token endpoint with internal/clientlimit
79 - rule: no-adapter-to-adapter-imports82 - rule: no-adapter-to-adapter-imports
80 path: internal/adapters/rpc/**83 path: internal/adapters/rpc/**
81 reason: rate-limits API clients with internal/clientlimit84 reason: rate-limits API clients with internal/clientlimit
M README.md +9 -0
@@ -121,6 +121,15 @@ Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts
121121
122122 Every job gets a **repository token** (`github.token`, `ermitegh_…`) when a runner takes it: it reads the repository of the run, even a private one, over Git and the REST subset below, writes nothing, and ends with the job (completion, cancel, lost runner) or five minutes after the job's deadline. It is not a stored token: `ermite token list` never shows it and no scope manages it.
123123
124+**OIDC.** ermite is an OpenID Connect provider for its jobs, as GitHub is: a job of a trusted run whose `permissions:` give `id-token: write` can ask for a signed ID token (`core.getIDToken()`, `aws-actions/configure-aws-credentials`, `hashicorp/vault-action` with `method: jwt`) and exchange it for short-lived credentials with a relying party that trusts ermite, instead of storing a long-lived secret. The issuer is `<ERMITE_PUBLIC_URL>/-/oidc`; its discovery document is `/-/oidc/.well-known/openid-configuration` and its keys `/-/oidc/.well-known/jwks`, both anonymous and cacheable. The runner sets `ACTIONS_ID_TOKEN_REQUEST_URL` (`/-/oidc/token?api-version=2.0`) and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` (an `ermiteoidc_…` secret, masked, valid only while the job runs and only at that endpoint) for run steps, JavaScript and Docker actions. Tokens are RS256, valid five minutes (`nbf` ten minutes before `iat`), with GitHub's claims (`sub`, `repository`, `ref`, `sha`, `workflow_ref`, `job_workflow_ref`, `run_id`, `run_attempt`, `event_name`, `environment`…) and `runner_environment: self-hosted`. `sub` is `repo:<owner>/<repo>:environment:<name>` for a job with an environment, else `repo:<owner>/<repo>:pull_request` for `pull_request` and `pull_request_target`, else `repo:<owner>/<repo>:ref:<ref>`; the default `aud` is `<ERMITE_PUBLIC_URL>/<owner>` and `getIDToken(audience)` picks another. Untrusted runs (an agent's push or merge request), jobs without `id-token: write` and servers without `ERMITE_SECRETS_KEY` get no variables, so the action fails as it does on GitHub without the permission. The signing key (RSA 3072) is generated at the first start and stored sealed with `ERMITE_SECRETS_KEY`; `ermite oidc rotate-key` (owner rights) replaces it, and the previous key stays in the JWKS for 24 hours. A relying party must reach the issuer to fetch the keys: AWS, Azure and GCP need it on the public Internet; a server reachable only on a private network works with a relying party on that network, such as Vault:
125+
126+```sh
127+vault auth enable jwt
128+vault write auth/jwt/config oidc_discovery_url="https://git.example.com/-/oidc" bound_issuer="https://git.example.com/-/oidc"
129+vault write auth/jwt/role/deploy role_type=jwt user_claim=sub bound_audiences="https://git.example.com/owner" \
130+ bound_claims_type=glob bound_claims='{"sub":"repo:owner/proj:ref:refs/heads/main"}' policies=deploy ttl=10m
131+```
132+
124133 An action of `ERMITE_OWNER/<repo>` that exists on this server wins over the same name on github.com. It is resolved **on behalf of whoever pushed the run**: ermite records the pusher with the run (the admin token, the owner's session, or the stored token by its id) and, at every answer, asks whether that pusher can read the action's repository, with the same rule as everywhere else: a public repository, or `repo:read` on a repository of the token's list. The token is looked up again each time, so a token revoked or expired since reads nothing for the run. A repository the pusher cannot read answers like one that does not exist (`repository not found`), whatever the ref, so it does not reveal itself. A job can therefore use an action from another private repository only when its pusher could read it. The runner gets an **archive token** for each such action: it is bound to that repository and that commit and downloads that one archive (`/-/github/api/repos/<owner>/<repo>/tarball/<sha>`) and nothing else. Git over HTTP, the repository endpoint and the rest of the API see it as a caller who can read nothing, not even a public repository. It goes with its job, and with the repository it reads if that is deleted. At most eight archives are written at once for one client address (`429` beyond: a runner host runs several jobs, and all come from one address) and sixteen on the server (`503`); both answers carry `Retry-After: 5`. A client that stops reading is cut after two minutes without progress.
125134
126135 **`pull_request`.** A merge request is a pull request for the workflows. ermite keeps `refs/pull/<n>/head` at the head of its source branch and, while that head merges cleanly into the target, `refs/pull/<n>/merge` at a test merge commit (parents: the target tip, then the head; message `Merge <head> into <base>`). Both are served to `git fetch`, never listed as branches or tags, and refused on every push (for any token, a job's included). The activities are `opened` (the request is created), `synchronize` (its source branch is pushed; each push runs for the commit it pushed, with the rights of whoever pushed it) and `closed` (closed or merged, with `merged` true or false; it uses the last test merge commit, or the head when there is none, and is never computed again, even when the source branch is gone); `reopened` is accepted in `types:` but merge requests cannot be reopened. Without `types:` a workflow gets `opened`, `synchronize` and `reopened`. The run has `github.ref` `refs/pull/<n>/merge`, `github.sha` the merge commit (the workflow file is read from it, so `actions/checkout` gets the merged tree), `github.head_ref` and `github.base_ref` set, and a payload shaped like GitHub's `pull_request` webhook with the fields ermite has. `branches` and `paths` (and their `-ignore` forms) apply to the target branch and to the files changed between the merge base and the head. A head that conflicts with the target makes no run for that update. The checks of the run are on the head commit. The run is trusted (secrets, write token) only when whoever caused the event is the owner **and** the head and the base it runs were pushed by the owner: its workflow comes from the merge commit, so from both branches. Each is checked against the record of who set the branch to exactly that commit, taken when the event happens (see `schedule`); a branch with no record, or whose record names another commit, is untrusted. The owner opening, pushing on or closing the request of a branch an agent pushed (or one targeting an agent's branch) gets an untrusted run. A closing runs the last test merge commit, whose head and base are checked; when it is the merge of an older head than the closing's, it is untrusted. The closing a merge makes has the merger's trust when the merger set the target to the merge result and the head is the one merged, even after the source branch is deleted. A job token starts no run.
@@ -121,6 +121,15 @@ Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts
121 121
122 Every job gets a **repository token** (`github.token`, `ermitegh_…`) when a runner takes it: it reads the repository of the run, even a private one, over Git and the REST subset below, writes nothing, and ends with the job (completion, cancel, lost runner) or five minutes after the job's deadline. It is not a stored token: `ermite token list` never shows it and no scope manages it.122 Every job gets a **repository token** (`github.token`, `ermitegh_…`) when a runner takes it: it reads the repository of the run, even a private one, over Git and the REST subset below, writes nothing, and ends with the job (completion, cancel, lost runner) or five minutes after the job's deadline. It is not a stored token: `ermite token list` never shows it and no scope manages it.
123 123
124+**OIDC.** ermite is an OpenID Connect provider for its jobs, as GitHub is: a job of a trusted run whose `permissions:` give `id-token: write` can ask for a signed ID token (`core.getIDToken()`, `aws-actions/configure-aws-credentials`, `hashicorp/vault-action` with `method: jwt`) and exchange it for short-lived credentials with a relying party that trusts ermite, instead of storing a long-lived secret. The issuer is `<ERMITE_PUBLIC_URL>/-/oidc`; its discovery document is `/-/oidc/.well-known/openid-configuration` and its keys `/-/oidc/.well-known/jwks`, both anonymous and cacheable. The runner sets `ACTIONS_ID_TOKEN_REQUEST_URL` (`/-/oidc/token?api-version=2.0`) and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` (an `ermiteoidc_…` secret, masked, valid only while the job runs and only at that endpoint) for run steps, JavaScript and Docker actions. Tokens are RS256, valid five minutes (`nbf` ten minutes before `iat`), with GitHub's claims (`sub`, `repository`, `ref`, `sha`, `workflow_ref`, `job_workflow_ref`, `run_id`, `run_attempt`, `event_name`, `environment`…) and `runner_environment: self-hosted`. `sub` is `repo:<owner>/<repo>:environment:<name>` for a job with an environment, else `repo:<owner>/<repo>:pull_request` for `pull_request` and `pull_request_target`, else `repo:<owner>/<repo>:ref:<ref>`; the default `aud` is `<ERMITE_PUBLIC_URL>/<owner>` and `getIDToken(audience)` picks another. Untrusted runs (an agent's push or merge request), jobs without `id-token: write` and servers without `ERMITE_SECRETS_KEY` get no variables, so the action fails as it does on GitHub without the permission. The signing key (RSA 3072) is generated at the first start and stored sealed with `ERMITE_SECRETS_KEY`; `ermite oidc rotate-key` (owner rights) replaces it, and the previous key stays in the JWKS for 24 hours. A relying party must reach the issuer to fetch the keys: AWS, Azure and GCP need it on the public Internet; a server reachable only on a private network works with a relying party on that network, such as Vault:
125+
126+```sh
127+vault auth enable jwt
128+vault write auth/jwt/config oidc_discovery_url="https://git.example.com/-/oidc" bound_issuer="https://git.example.com/-/oidc"
129+vault write auth/jwt/role/deploy role_type=jwt user_claim=sub bound_audiences="https://git.example.com/owner" \
130+ bound_claims_type=glob bound_claims='{"sub":"repo:owner/proj:ref:refs/heads/main"}' policies=deploy ttl=10m
131+```
132+
124 An action of `ERMITE_OWNER/<repo>` that exists on this server wins over the same name on github.com. It is resolved **on behalf of whoever pushed the run**: ermite records the pusher with the run (the admin token, the owner's session, or the stored token by its id) and, at every answer, asks whether that pusher can read the action's repository, with the same rule as everywhere else: a public repository, or `repo:read` on a repository of the token's list. The token is looked up again each time, so a token revoked or expired since reads nothing for the run. A repository the pusher cannot read answers like one that does not exist (`repository not found`), whatever the ref, so it does not reveal itself. A job can therefore use an action from another private repository only when its pusher could read it. The runner gets an **archive token** for each such action: it is bound to that repository and that commit and downloads that one archive (`/-/github/api/repos/<owner>/<repo>/tarball/<sha>`) and nothing else. Git over HTTP, the repository endpoint and the rest of the API see it as a caller who can read nothing, not even a public repository. It goes with its job, and with the repository it reads if that is deleted. At most eight archives are written at once for one client address (`429` beyond: a runner host runs several jobs, and all come from one address) and sixteen on the server (`503`); both answers carry `Retry-After: 5`. A client that stops reading is cut after two minutes without progress.133 An action of `ERMITE_OWNER/<repo>` that exists on this server wins over the same name on github.com. It is resolved **on behalf of whoever pushed the run**: ermite records the pusher with the run (the admin token, the owner's session, or the stored token by its id) and, at every answer, asks whether that pusher can read the action's repository, with the same rule as everywhere else: a public repository, or `repo:read` on a repository of the token's list. The token is looked up again each time, so a token revoked or expired since reads nothing for the run. A repository the pusher cannot read answers like one that does not exist (`repository not found`), whatever the ref, so it does not reveal itself. A job can therefore use an action from another private repository only when its pusher could read it. The runner gets an **archive token** for each such action: it is bound to that repository and that commit and downloads that one archive (`/-/github/api/repos/<owner>/<repo>/tarball/<sha>`) and nothing else. Git over HTTP, the repository endpoint and the rest of the API see it as a caller who can read nothing, not even a public repository. It goes with its job, and with the repository it reads if that is deleted. At most eight archives are written at once for one client address (`429` beyond: a runner host runs several jobs, and all come from one address) and sixteen on the server (`503`); both answers carry `Retry-After: 5`. A client that stops reading is cut after two minutes without progress.
125 134
126 **`pull_request`.** A merge request is a pull request for the workflows. ermite keeps `refs/pull/<n>/head` at the head of its source branch and, while that head merges cleanly into the target, `refs/pull/<n>/merge` at a test merge commit (parents: the target tip, then the head; message `Merge <head> into <base>`). Both are served to `git fetch`, never listed as branches or tags, and refused on every push (for any token, a job's included). The activities are `opened` (the request is created), `synchronize` (its source branch is pushed; each push runs for the commit it pushed, with the rights of whoever pushed it) and `closed` (closed or merged, with `merged` true or false; it uses the last test merge commit, or the head when there is none, and is never computed again, even when the source branch is gone); `reopened` is accepted in `types:` but merge requests cannot be reopened. Without `types:` a workflow gets `opened`, `synchronize` and `reopened`. The run has `github.ref` `refs/pull/<n>/merge`, `github.sha` the merge commit (the workflow file is read from it, so `actions/checkout` gets the merged tree), `github.head_ref` and `github.base_ref` set, and a payload shaped like GitHub's `pull_request` webhook with the fields ermite has. `branches` and `paths` (and their `-ignore` forms) apply to the target branch and to the files changed between the merge base and the head. A head that conflicts with the target makes no run for that update. The checks of the run are on the head commit. The run is trusted (secrets, write token) only when whoever caused the event is the owner **and** the head and the base it runs were pushed by the owner: its workflow comes from the merge commit, so from both branches. Each is checked against the record of who set the branch to exactly that commit, taken when the event happens (see `schedule`); a branch with no record, or whose record names another commit, is untrusted. The owner opening, pushing on or closing the request of a branch an agent pushed (or one targeting an agent's branch) gets an untrusted run. A closing runs the last test merge commit, whose head and base are checked; when it is the merge of an older head than the closing's, it is untrusted. The closing a merge makes has the merger's trust when the merger set the target to the merge result and the head is the one merged, even after the source branch is deleted. A job token starts no run.135 **`pull_request`.** A merge request is a pull request for the workflows. ermite keeps `refs/pull/<n>/head` at the head of its source branch and, while that head merges cleanly into the target, `refs/pull/<n>/merge` at a test merge commit (parents: the target tip, then the head; message `Merge <head> into <base>`). Both are served to `git fetch`, never listed as branches or tags, and refused on every push (for any token, a job's included). The activities are `opened` (the request is created), `synchronize` (its source branch is pushed; each push runs for the commit it pushed, with the rights of whoever pushed it) and `closed` (closed or merged, with `merged` true or false; it uses the last test merge commit, or the head when there is none, and is never computed again, even when the source branch is gone); `reopened` is accepted in `types:` but merge requests cannot be reopened. Without `types:` a workflow gets `opened`, `synchronize` and `reopened`. The run has `github.ref` `refs/pull/<n>/merge`, `github.sha` the merge commit (the workflow file is read from it, so `actions/checkout` gets the merged tree), `github.head_ref` and `github.base_ref` set, and a payload shaped like GitHub's `pull_request` webhook with the fields ermite has. `branches` and `paths` (and their `-ignore` forms) apply to the target branch and to the files changed between the merge base and the head. A head that conflicts with the target makes no run for that update. The checks of the run are on the head commit. The run is trusted (secrets, write token) only when whoever caused the event is the owner **and** the head and the base it runs were pushed by the owner: its workflow comes from the merge commit, so from both branches. Each is checked against the record of who set the branch to exactly that commit, taken when the event happens (see `schedule`); a branch with no record, or whose record names another commit, is untrusted. The owner opening, pushing on or closing the request of a branch an agent pushed (or one targeting an agent's branch) gets an untrusted run. A closing runs the last test merge commit, whose head and base are checked; when it is the merge of an older head than the closing's, it is untrusted. The closing a merge makes has the merger's trust when the merger set the target to the merge result and the head is the one merged, even after the source branch is deleted. A job token starts no run.
M gen/ermite/storage/ci.pb.go +16 -3
@@ -1801,7 +1801,12 @@ type CIRepoToken struct {
18011801 // what it may do on its repository follows from them (domain.RepoTokenPrincipal).
18021802 // Absent: the restricted default, read-only, as tokens issued before the
18031803 // field existed (an older binary reads a newer token as read-only too).
1804- Permissions map[string]string `protobuf:"bytes,11,rep,name=permissions,proto3" json:"permissions,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
1804+ Permissions map[string]string `protobuf:"bytes,11,rep,name=permissions,proto3" json:"permissions,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
1805+ // Set for the ID token request token of the job ("ermiteoidc_"), which only
1806+ // the OIDC token endpoint accepts. A binary that predates the field reads it
1807+ // as a job's own token, but never looks it up: it only resolves secrets with
1808+ // the "ermitegh_" prefix, whose hashes are not those of these tokens.
1809+ Oidc bool `protobuf:"varint,12,opt,name=oidc,proto3" json:"oidc,omitempty"`
18051810 unknownFields protoimpl.UnknownFields
18061811 sizeCache protoimpl.SizeCache
18071812 }
@@ -1913,6 +1918,13 @@ func (x *CIRepoToken) GetPermissions() map[string]string {
19131918 return nil
19141919 }
19151920
1921+func (x *CIRepoToken) GetOidc() bool {
1922+ if x != nil {
1923+ return x.Oidc
1924+ }
1925+ return false
1926+}
1927+
19161928 // CIActionResolution is the resolution of an action reference for a run
19171929 // attempt, stored under "actres/<run:020d>/<run attempt:010d>/<sha256 of key>"
19181930 // so that every job of the attempt gets the same commit.
@@ -2726,7 +2738,7 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
27262738 "\x02os\x18\a \x01(\tR\x02os\x12?\n" +
27272739 "\rregistered_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\fregisteredAt\x12\x1d\n" +
27282740 "\n" +
2729- "token_hash\x18\t \x01(\fR\ttokenHash\"\xc6\x03\n" +
2741+ "token_hash\x18\t \x01(\fR\ttokenHash\"\xda\x03\n" +
27302742 "\vCIRepoToken\x12\x0e\n" +
27312743 "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
27322744 "\x04hash\x18\x02 \x01(\fR\x04hash\x12\x12\n" +
@@ -2742,7 +2754,8 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
27422754 "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x10\n" +
27432755 "\x03sha\x18\n" +
27442756 " \x01(\tR\x03sha\x12N\n" +
2745- "\vpermissions\x18\v \x03(\v2,.ermite.storage.CIRepoToken.PermissionsEntryR\vpermissions\x1a>\n" +
2757+ "\vpermissions\x18\v \x03(\v2,.ermite.storage.CIRepoToken.PermissionsEntryR\vpermissions\x12\x12\n" +
2758+ "\x04oidc\x18\f \x01(\bR\x04oidc\x1a>\n" +
27462759 "\x10PermissionsEntry\x12\x10\n" +
27472760 "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
27482761 "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x89\x01\n" +
@@ -1801,7 +1801,12 @@ type CIRepoToken struct {
1801 // what it may do on its repository follows from them (domain.RepoTokenPrincipal).1801 // what it may do on its repository follows from them (domain.RepoTokenPrincipal).
1802 // Absent: the restricted default, read-only, as tokens issued before the1802 // Absent: the restricted default, read-only, as tokens issued before the
1803 // field existed (an older binary reads a newer token as read-only too).1803 // field existed (an older binary reads a newer token as read-only too).
1804- Permissions map[string]string `protobuf:"bytes,11,rep,name=permissions,proto3" json:"permissions,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`1804+ Permissions map[string]string `protobuf:"bytes,11,rep,name=permissions,proto3" json:"permissions,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
1805+ // Set for the ID token request token of the job ("ermiteoidc_"), which only
1806+ // the OIDC token endpoint accepts. A binary that predates the field reads it
1807+ // as a job's own token, but never looks it up: it only resolves secrets with
1808+ // the "ermitegh_" prefix, whose hashes are not those of these tokens.
1809+ Oidc bool `protobuf:"varint,12,opt,name=oidc,proto3" json:"oidc,omitempty"`
1805 unknownFields protoimpl.UnknownFields1810 unknownFields protoimpl.UnknownFields
1806 sizeCache protoimpl.SizeCache1811 sizeCache protoimpl.SizeCache
1807 }1812 }
@@ -1913,6 +1918,13 @@ func (x *CIRepoToken) GetPermissions() map[string]string {
1913 return nil1918 return nil
1914 }1919 }
1915 1920
1921+func (x *CIRepoToken) GetOidc() bool {
1922+ if x != nil {
1923+ return x.Oidc
1924+ }
1925+ return false
1926+}
1927+
1916 // CIActionResolution is the resolution of an action reference for a run1928 // CIActionResolution is the resolution of an action reference for a run
1917 // attempt, stored under "actres/<run:020d>/<run attempt:010d>/<sha256 of key>"1929 // attempt, stored under "actres/<run:020d>/<run attempt:010d>/<sha256 of key>"
1918 // so that every job of the attempt gets the same commit.1930 // so that every job of the attempt gets the same commit.
@@ -2726,7 +2738,7 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
2726 "\x02os\x18\a \x01(\tR\x02os\x12?\n" +2738 "\x02os\x18\a \x01(\tR\x02os\x12?\n" +
2727 "\rregistered_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\fregisteredAt\x12\x1d\n" +2739 "\rregistered_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\fregisteredAt\x12\x1d\n" +
2728 "\n" +2740 "\n" +
2729- "token_hash\x18\t \x01(\fR\ttokenHash\"\xc6\x03\n" +2741+ "token_hash\x18\t \x01(\fR\ttokenHash\"\xda\x03\n" +
2730 "\vCIRepoToken\x12\x0e\n" +2742 "\vCIRepoToken\x12\x0e\n" +
2731 "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +2743 "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
2732 "\x04hash\x18\x02 \x01(\fR\x04hash\x12\x12\n" +2744 "\x04hash\x18\x02 \x01(\fR\x04hash\x12\x12\n" +
@@ -2742,7 +2754,8 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
2742 "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x10\n" +2754 "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x10\n" +
2743 "\x03sha\x18\n" +2755 "\x03sha\x18\n" +
2744 " \x01(\tR\x03sha\x12N\n" +2756 " \x01(\tR\x03sha\x12N\n" +
2745- "\vpermissions\x18\v \x03(\v2,.ermite.storage.CIRepoToken.PermissionsEntryR\vpermissions\x1a>\n" +2757+ "\vpermissions\x18\v \x03(\v2,.ermite.storage.CIRepoToken.PermissionsEntryR\vpermissions\x12\x12\n" +
2758+ "\x04oidc\x18\f \x01(\bR\x04oidc\x1a>\n" +
2746 "\x10PermissionsEntry\x12\x10\n" +2759 "\x10PermissionsEntry\x12\x10\n" +
2747 "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +2760 "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
2748 "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x89\x01\n" +2761 "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x89\x01\n" +
A gen/ermite/storage/oidc.pb.go +157 -0 generated file: collapsed
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.3.0
16+ bnema.dev/ermite-api v0.4.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
1919 connectrpc.com/connect v1.21.0
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.3.016+ bnema.dev/ermite-api v0.4.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c318 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
M go.sum +2 -2 generated file: collapsed
M internal/adapters/kvstore/ci_token.go +2 -2
@@ -62,7 +62,7 @@ func resolutionKey(runID int64, attempt int32, key string) []byte {
6262 func encodeRepoToken(t domain.CIRepoToken) []byte {
6363 return mustMarshal(&storagepb.CIRepoToken{
6464 Id: t.ID, Hash: t.Hash[:], Repo: t.Repo.String(), RunId: t.RunID, RunAttempt: t.RunAttempt, JobId: t.JobID,
65- Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt), Sha: t.Sha, Permissions: t.Permissions,
65+ Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt), Sha: t.Sha, Permissions: t.Permissions, Oidc: t.OIDC,
6666 })
6767 }
6868
@@ -78,7 +78,7 @@ func decodeRepoToken(val []byte) (domain.CIRepoToken, error) {
7878 t := domain.CIRepoToken{
7979 ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(),
8080 Archive: pb.GetArchive(), ExpiresAt: decodeTime(pb.GetExpiresAt()), CreatedAt: decodeTime(pb.GetCreatedAt()),
81- Sha: pb.GetSha(), Permissions: pb.GetPermissions(),
81+ Sha: pb.GetSha(), Permissions: pb.GetPermissions(), OIDC: pb.GetOidc(),
8282 }
8383 copy(t.Hash[:], pb.GetHash())
8484 return t, nil
@@ -62,7 +62,7 @@ func resolutionKey(runID int64, attempt int32, key string) []byte {
62 func encodeRepoToken(t domain.CIRepoToken) []byte {62 func encodeRepoToken(t domain.CIRepoToken) []byte {
63 return mustMarshal(&storagepb.CIRepoToken{63 return mustMarshal(&storagepb.CIRepoToken{
64 Id: t.ID, Hash: t.Hash[:], Repo: t.Repo.String(), RunId: t.RunID, RunAttempt: t.RunAttempt, JobId: t.JobID,64 Id: t.ID, Hash: t.Hash[:], Repo: t.Repo.String(), RunId: t.RunID, RunAttempt: t.RunAttempt, JobId: t.JobID,
65- Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt), Sha: t.Sha, Permissions: t.Permissions,65+ Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt), Sha: t.Sha, Permissions: t.Permissions, Oidc: t.OIDC,
66 })66 })
67 }67 }
68 68
@@ -78,7 +78,7 @@ func decodeRepoToken(val []byte) (domain.CIRepoToken, error) {
78 t := domain.CIRepoToken{78 t := domain.CIRepoToken{
79 ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(),79 ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(),
80 Archive: pb.GetArchive(), ExpiresAt: decodeTime(pb.GetExpiresAt()), CreatedAt: decodeTime(pb.GetCreatedAt()),80 Archive: pb.GetArchive(), ExpiresAt: decodeTime(pb.GetExpiresAt()), CreatedAt: decodeTime(pb.GetCreatedAt()),
81- Sha: pb.GetSha(), Permissions: pb.GetPermissions(),81+ Sha: pb.GetSha(), Permissions: pb.GetPermissions(), OIDC: pb.GetOidc(),
82 }82 }
83 copy(t.Hash[:], pb.GetHash())83 copy(t.Hash[:], pb.GetHash())
84 return t, nil84 return t, nil
A internal/adapters/kvstore/oidc.go +72 -0
@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "fmt"
8+ "slices"
9+ "strings"
10+
11+ kv "bnema.dev/ermite-kv"
12+ "google.golang.org/protobuf/proto"
13+
14+ storagepb "bnema.dev/ermite/gen/ermite/storage"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// Key layout of the signing keys of the OIDC ID tokens (CI-4):
20+//
21+// oidc/key/<kid> OIDCKey (the private key sealed, see secretbox)
22+const oidcKeyPrefix = "oidc/key/"
23+
24+var _ ports.OIDCKeyStore = (*Store)(nil)
25+
26+// ListOIDCKeys returns every stored key, oldest first.
27+func (s *Store) ListOIDCKeys(ctx context.Context) ([]domain.OIDCKey, error) {
28+ var out []domain.OIDCKey
29+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(oidcKeyPrefix)) {
30+ var pb storagepb.OIDCKey
31+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
32+ return nil, fmt.Errorf("decode oidc key %s: %w", key, err)
33+ }
34+ out = append(out, domain.OIDCKey{
35+ ID: strings.TrimPrefix(string(key), oidcKeyPrefix), Sealed: pb.GetSealed(),
36+ CreatedAt: decodeTime(pb.GetCreatedAt()), RetiredAt: decodeTime(pb.GetRetiredAt()),
37+ })
38+ }
39+ slices.SortStableFunc(out, func(a, b domain.OIDCKey) int { return a.CreatedAt.Compare(b.CreatedAt) })
40+ return out, nil
41+}
42+
43+// SaveOIDCKeys writes the keys of put (created or replaced) and deletes those
44+// of drop, in one batch.
45+func (s *Store) SaveOIDCKeys(ctx context.Context, put []domain.OIDCKey, drop []string) error {
46+ var b kv.Batch
47+ for _, k := range put {
48+ if k.ID == "" || strings.Contains(k.ID, "/") {
49+ return fmt.Errorf("%w: oidc key id %q", domain.ErrInvalid, k.ID)
50+ }
51+ b.Put([]byte(domain.OIDCKeyPath(k.ID)), mustMarshal(&storagepb.OIDCKey{
52+ Sealed: k.Sealed, CreatedAt: encodeTime(k.CreatedAt), RetiredAt: encodeTime(k.RetiredAt),
53+ }))
54+ }
55+ for _, id := range drop {
56+ b.Delete([]byte(domain.OIDCKeyPath(id)))
57+ }
58+ if _, err := s.db.Apply(ctx, &b); err != nil {
59+ return fmt.Errorf("store the oidc keys: %w", err)
60+ }
61+ return nil
62+}
63+
64+// RawOIDCKey returns the stored value of the key, as is. Tests use it to check
65+// that no private key is stored in the clear.
66+func (s *Store) RawOIDCKey(ctx context.Context, kid string) ([]byte, bool) {
67+ item, ok := s.db.Get(ctx, []byte(domain.OIDCKeyPath(kid)))
68+ if !ok {
69+ return nil, false
70+ }
71+ return item.Value, true
72+}
@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "fmt"
8+ "slices"
9+ "strings"
10+
11+ kv "bnema.dev/ermite-kv"
12+ "google.golang.org/protobuf/proto"
13+
14+ storagepb "bnema.dev/ermite/gen/ermite/storage"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// Key layout of the signing keys of the OIDC ID tokens (CI-4):
20+//
21+// oidc/key/<kid> OIDCKey (the private key sealed, see secretbox)
22+const oidcKeyPrefix = "oidc/key/"
23+
24+var _ ports.OIDCKeyStore = (*Store)(nil)
25+
26+// ListOIDCKeys returns every stored key, oldest first.
27+func (s *Store) ListOIDCKeys(ctx context.Context) ([]domain.OIDCKey, error) {
28+ var out []domain.OIDCKey
29+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(oidcKeyPrefix)) {
30+ var pb storagepb.OIDCKey
31+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
32+ return nil, fmt.Errorf("decode oidc key %s: %w", key, err)
33+ }
34+ out = append(out, domain.OIDCKey{
35+ ID: strings.TrimPrefix(string(key), oidcKeyPrefix), Sealed: pb.GetSealed(),
36+ CreatedAt: decodeTime(pb.GetCreatedAt()), RetiredAt: decodeTime(pb.GetRetiredAt()),
37+ })
38+ }
39+ slices.SortStableFunc(out, func(a, b domain.OIDCKey) int { return a.CreatedAt.Compare(b.CreatedAt) })
40+ return out, nil
41+}
42+
43+// SaveOIDCKeys writes the keys of put (created or replaced) and deletes those
44+// of drop, in one batch.
45+func (s *Store) SaveOIDCKeys(ctx context.Context, put []domain.OIDCKey, drop []string) error {
46+ var b kv.Batch
47+ for _, k := range put {
48+ if k.ID == "" || strings.Contains(k.ID, "/") {
49+ return fmt.Errorf("%w: oidc key id %q", domain.ErrInvalid, k.ID)
50+ }
51+ b.Put([]byte(domain.OIDCKeyPath(k.ID)), mustMarshal(&storagepb.OIDCKey{
52+ Sealed: k.Sealed, CreatedAt: encodeTime(k.CreatedAt), RetiredAt: encodeTime(k.RetiredAt),
53+ }))
54+ }
55+ for _, id := range drop {
56+ b.Delete([]byte(domain.OIDCKeyPath(id)))
57+ }
58+ if _, err := s.db.Apply(ctx, &b); err != nil {
59+ return fmt.Errorf("store the oidc keys: %w", err)
60+ }
61+ return nil
62+}
63+
64+// RawOIDCKey returns the stored value of the key, as is. Tests use it to check
65+// that no private key is stored in the clear.
66+func (s *Store) RawOIDCKey(ctx context.Context, kid string) ([]byte, bool) {
67+ item, ok := s.db.Get(ctx, []byte(domain.OIDCKeyPath(kid)))
68+ if !ok {
69+ return nil, false
70+ }
71+ return item.Value, true
72+}
A internal/adapters/oidchttp/handler.go +179 -0
@@ -0,0 +1,179 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package oidchttp serves the OIDC provider of CI jobs under /-/oidc, the
4+// issuer (CI-4, D5):
5+//
6+// GET /-/oidc/.well-known/openid-configuration discovery, anonymous
7+// GET /-/oidc/.well-known/jwks public keys, anonymous
8+// GET /-/oidc/token?api-version=2.0[&audience=…] {"value":"<jwt>"}
9+//
10+// The token endpoint is the ACTIONS_ID_TOKEN_REQUEST_URL of a job, called by
11+// @actions/core's getIDToken with "Authorization: Bearer <the job's request
12+// token>". Everything is 404 when the server issues no ID tokens.
13+package oidchttp
14+
15+import (
16+ "encoding/json"
17+ "errors"
18+ "io"
19+ "net/http"
20+ "net/netip"
21+ "strconv"
22+ "strings"
23+
24+ "github.com/bnema/zerowrap"
25+
26+ "bnema.dev/ermite/internal/clientlimit"
27+ "bnema.dev/ermite/internal/domain"
28+ "bnema.dev/ermite/internal/ports"
29+)
30+
31+// Prefix is where the provider is mounted: the path of the issuer.
32+const Prefix = "/-/oidc"
33+
34+// Patterns are the ServeMux patterns the handler serves: its whole prefix.
35+var Patterns = []string{Prefix + "/"}
36+
37+// How long clients may cache the public documents. The JWKS is short: after a
38+// rotation the new key signs at once, and relying parties that do not refetch
39+// on an unknown kid see it within this delay.
40+const (
41+ discoveryMaxAge = "public, max-age=3600"
42+ jwksMaxAge = "public, max-age=300"
43+)
44+
45+// Config configures a Handler.
46+type Config struct {
47+ OIDC ports.OIDCService
48+ // TrustedProxies are the networks allowed to set X-Forwarded-For.
49+ TrustedProxies []netip.Prefix
50+ // Limiter counts failed authentications; nil builds a default one.
51+ Limiter ports.FailureLimiter
52+}
53+
54+// Handler serves the provider.
55+type Handler struct {
56+ cfg Config
57+ limiter ports.FailureLimiter
58+}
59+
60+// New builds a Handler.
61+func New(cfg Config) *Handler {
62+ h := &Handler{cfg: cfg, limiter: cfg.Limiter}
63+ if h.limiter == nil {
64+ h.limiter = clientlimit.NewLimiter(nil)
65+ }
66+ return h
67+}
68+
69+// ServeHTTP implements http.Handler.
70+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
71+ w.Header().Set("X-Content-Type-Options", "nosniff")
72+ path := strings.TrimPrefix(r.URL.Path, Prefix)
73+ if !h.cfg.OIDC.OIDCEnabled() {
74+ w.Header().Set("Cache-Control", "no-store")
75+ jsonError(w, http.StatusNotFound, "Not Found")
76+ return
77+ }
78+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
79+ w.Header().Set("Allow", "GET, HEAD")
80+ jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed")
81+ return
82+ }
83+ switch path {
84+ case "/.well-known/openid-configuration":
85+ h.public(w, r, discoveryMaxAge, h.cfg.OIDC.OIDCDiscovery)
86+ case "/.well-known/jwks":
87+ h.public(w, r, jwksMaxAge, h.cfg.OIDC.OIDCKeys)
88+ case "/token":
89+ h.token(w, r)
90+ default:
91+ w.Header().Set("Cache-Control", "no-store")
92+ jsonError(w, http.StatusNotFound, "Not Found")
93+ }
94+}
95+
96+// public answers a document anyone may read and cache.
97+func (h *Handler) public(w http.ResponseWriter, r *http.Request, cache string, doc func() ([]byte, error)) {
98+ body, err := doc()
99+ if err != nil {
100+ w.Header().Set("Cache-Control", "no-store")
101+ h.fail(w, r, err)
102+ return
103+ }
104+ w.Header().Set("Cache-Control", cache)
105+ writeJSON(w, r, body)
106+}
107+
108+// token answers an ID token for the job whose request token is the bearer.
109+// api-version is accepted as GitHub's endpoint takes it, and not checked.
110+func (h *Handler) token(w http.ResponseWriter, r *http.Request) {
111+ w.Header().Set("Cache-Control", "no-store")
112+ ctx := r.Context()
113+ addr := clientlimit.ClientAddr(r, h.cfg.TrustedProxies)
114+ if blocked, _ := h.limiter.Blocked(ctx, addr); blocked {
115+ jsonError(w, http.StatusTooManyRequests, "Too many failed authentications")
116+ return
117+ }
118+ secret, ok := bearer(r)
119+ if !ok {
120+ w.Header().Set("WWW-Authenticate", `Bearer realm="ermite oidc"`)
121+ jsonError(w, http.StatusUnauthorized, "Bad credentials")
122+ return
123+ }
124+ jwt, err := h.cfg.OIDC.IDToken(ctx, secret, r.URL.Query().Get("audience"))
125+ if err != nil {
126+ if errors.Is(err, domain.ErrUnauthenticated) {
127+ h.limiter.Fail(ctx, addr)
128+ w.Header().Set("WWW-Authenticate", `Bearer realm="ermite oidc"`)
129+ jsonError(w, http.StatusUnauthorized, "Bad credentials")
130+ return
131+ }
132+ h.fail(w, r, err)
133+ return
134+ }
135+ body, err := json.Marshal(map[string]string{"value": jwt})
136+ if err != nil {
137+ h.fail(w, r, err)
138+ return
139+ }
140+ writeJSON(w, r, body)
141+}
142+
143+// bearer extracts the token of "Authorization: Bearer <token>".
144+func bearer(r *http.Request) (string, bool) {
145+ scheme, tok, ok := strings.Cut(r.Header.Get("Authorization"), " ")
146+ if !ok || !strings.EqualFold(scheme, "Bearer") {
147+ return "", false
148+ }
149+ tok = strings.TrimSpace(tok)
150+ return tok, tok != ""
151+}
152+
153+func (h *Handler) fail(w http.ResponseWriter, r *http.Request, err error) {
154+ switch {
155+ case errors.Is(err, domain.ErrNotFound):
156+ jsonError(w, http.StatusNotFound, "Not Found")
157+ case domain.IsInvalid(err):
158+ jsonError(w, http.StatusBadRequest, err.Error())
159+ default:
160+ log := zerowrap.FromCtx(r.Context())
161+ log.Error().Err(err).Msg("oidc request failed")
162+ jsonError(w, http.StatusInternalServerError, "Server Error")
163+ }
164+}
165+
166+func jsonError(w http.ResponseWriter, status int, msg string) {
167+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
168+ w.WriteHeader(status)
169+ _, _ = io.WriteString(w, `{"message":`+strconv.Quote(msg)+`}`)
170+}
171+
172+func writeJSON(w http.ResponseWriter, r *http.Request, body []byte) {
173+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
174+ w.Header().Set("Content-Length", strconv.Itoa(len(body)))
175+ w.WriteHeader(http.StatusOK)
176+ if r.Method != http.MethodHead {
177+ _, _ = w.Write(body)
178+ }
179+}
@@ -0,0 +1,179 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package oidchttp serves the OIDC provider of CI jobs under /-/oidc, the
4+// issuer (CI-4, D5):
5+//
6+// GET /-/oidc/.well-known/openid-configuration discovery, anonymous
7+// GET /-/oidc/.well-known/jwks public keys, anonymous
8+// GET /-/oidc/token?api-version=2.0[&audience=…] {"value":"<jwt>"}
9+//
10+// The token endpoint is the ACTIONS_ID_TOKEN_REQUEST_URL of a job, called by
11+// @actions/core's getIDToken with "Authorization: Bearer <the job's request
12+// token>". Everything is 404 when the server issues no ID tokens.
13+package oidchttp
14+
15+import (
16+ "encoding/json"
17+ "errors"
18+ "io"
19+ "net/http"
20+ "net/netip"
21+ "strconv"
22+ "strings"
23+
24+ "github.com/bnema/zerowrap"
25+
26+ "bnema.dev/ermite/internal/clientlimit"
27+ "bnema.dev/ermite/internal/domain"
28+ "bnema.dev/ermite/internal/ports"
29+)
30+
31+// Prefix is where the provider is mounted: the path of the issuer.
32+const Prefix = "/-/oidc"
33+
34+// Patterns are the ServeMux patterns the handler serves: its whole prefix.
35+var Patterns = []string{Prefix + "/"}
36+
37+// How long clients may cache the public documents. The JWKS is short: after a
38+// rotation the new key signs at once, and relying parties that do not refetch
39+// on an unknown kid see it within this delay.
40+const (
41+ discoveryMaxAge = "public, max-age=3600"
42+ jwksMaxAge = "public, max-age=300"
43+)
44+
45+// Config configures a Handler.
46+type Config struct {
47+ OIDC ports.OIDCService
48+ // TrustedProxies are the networks allowed to set X-Forwarded-For.
49+ TrustedProxies []netip.Prefix
50+ // Limiter counts failed authentications; nil builds a default one.
51+ Limiter ports.FailureLimiter
52+}
53+
54+// Handler serves the provider.
55+type Handler struct {
56+ cfg Config
57+ limiter ports.FailureLimiter
58+}
59+
60+// New builds a Handler.
61+func New(cfg Config) *Handler {
62+ h := &Handler{cfg: cfg, limiter: cfg.Limiter}
63+ if h.limiter == nil {
64+ h.limiter = clientlimit.NewLimiter(nil)
65+ }
66+ return h
67+}
68+
69+// ServeHTTP implements http.Handler.
70+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
71+ w.Header().Set("X-Content-Type-Options", "nosniff")
72+ path := strings.TrimPrefix(r.URL.Path, Prefix)
73+ if !h.cfg.OIDC.OIDCEnabled() {
74+ w.Header().Set("Cache-Control", "no-store")
75+ jsonError(w, http.StatusNotFound, "Not Found")
76+ return
77+ }
78+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
79+ w.Header().Set("Allow", "GET, HEAD")
80+ jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed")
81+ return
82+ }
83+ switch path {
84+ case "/.well-known/openid-configuration":
85+ h.public(w, r, discoveryMaxAge, h.cfg.OIDC.OIDCDiscovery)
86+ case "/.well-known/jwks":
87+ h.public(w, r, jwksMaxAge, h.cfg.OIDC.OIDCKeys)
88+ case "/token":
89+ h.token(w, r)
90+ default:
91+ w.Header().Set("Cache-Control", "no-store")
92+ jsonError(w, http.StatusNotFound, "Not Found")
93+ }
94+}
95+
96+// public answers a document anyone may read and cache.
97+func (h *Handler) public(w http.ResponseWriter, r *http.Request, cache string, doc func() ([]byte, error)) {
98+ body, err := doc()
99+ if err != nil {
100+ w.Header().Set("Cache-Control", "no-store")
101+ h.fail(w, r, err)
102+ return
103+ }
104+ w.Header().Set("Cache-Control", cache)
105+ writeJSON(w, r, body)
106+}
107+
108+// token answers an ID token for the job whose request token is the bearer.
109+// api-version is accepted as GitHub's endpoint takes it, and not checked.
110+func (h *Handler) token(w http.ResponseWriter, r *http.Request) {
111+ w.Header().Set("Cache-Control", "no-store")
112+ ctx := r.Context()
113+ addr := clientlimit.ClientAddr(r, h.cfg.TrustedProxies)
114+ if blocked, _ := h.limiter.Blocked(ctx, addr); blocked {
115+ jsonError(w, http.StatusTooManyRequests, "Too many failed authentications")
116+ return
117+ }
118+ secret, ok := bearer(r)
119+ if !ok {
120+ w.Header().Set("WWW-Authenticate", `Bearer realm="ermite oidc"`)
121+ jsonError(w, http.StatusUnauthorized, "Bad credentials")
122+ return
123+ }
124+ jwt, err := h.cfg.OIDC.IDToken(ctx, secret, r.URL.Query().Get("audience"))
125+ if err != nil {
126+ if errors.Is(err, domain.ErrUnauthenticated) {
127+ h.limiter.Fail(ctx, addr)
128+ w.Header().Set("WWW-Authenticate", `Bearer realm="ermite oidc"`)
129+ jsonError(w, http.StatusUnauthorized, "Bad credentials")
130+ return
131+ }
132+ h.fail(w, r, err)
133+ return
134+ }
135+ body, err := json.Marshal(map[string]string{"value": jwt})
136+ if err != nil {
137+ h.fail(w, r, err)
138+ return
139+ }
140+ writeJSON(w, r, body)
141+}
142+
143+// bearer extracts the token of "Authorization: Bearer <token>".
144+func bearer(r *http.Request) (string, bool) {
145+ scheme, tok, ok := strings.Cut(r.Header.Get("Authorization"), " ")
146+ if !ok || !strings.EqualFold(scheme, "Bearer") {
147+ return "", false
148+ }
149+ tok = strings.TrimSpace(tok)
150+ return tok, tok != ""
151+}
152+
153+func (h *Handler) fail(w http.ResponseWriter, r *http.Request, err error) {
154+ switch {
155+ case errors.Is(err, domain.ErrNotFound):
156+ jsonError(w, http.StatusNotFound, "Not Found")
157+ case domain.IsInvalid(err):
158+ jsonError(w, http.StatusBadRequest, err.Error())
159+ default:
160+ log := zerowrap.FromCtx(r.Context())
161+ log.Error().Err(err).Msg("oidc request failed")
162+ jsonError(w, http.StatusInternalServerError, "Server Error")
163+ }
164+}
165+
166+func jsonError(w http.ResponseWriter, status int, msg string) {
167+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
168+ w.WriteHeader(status)
169+ _, _ = io.WriteString(w, `{"message":`+strconv.Quote(msg)+`}`)
170+}
171+
172+func writeJSON(w http.ResponseWriter, r *http.Request, body []byte) {
173+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
174+ w.Header().Set("Content-Length", strconv.Itoa(len(body)))
175+ w.WriteHeader(http.StatusOK)
176+ if r.Method != http.MethodHead {
177+ _, _ = w.Write(body)
178+ }
179+}
M internal/adapters/rpc/api.go +12 -1
@@ -39,6 +39,10 @@ var Features = []string{"repo.update", "events.resume", "merge_requests", "merge
3939 // FeaturePasskeys is listed by GetVersion when passkeys are available.
4040 const FeaturePasskeys = "passkeys"
4141
42+// FeatureCIOIDC is listed by GetVersion when the server issues OIDC ID tokens
43+// to CI jobs (Job.id_token_url, ServerService.RotateOIDCKey).
44+const FeatureCIOIDC = "ci.oidc"
45+
4246 // Config configures the API.
4347 type Config struct {
4448 Auth ports.AuthService
@@ -77,6 +81,10 @@ type Config struct {
7781 Secrets ports.SecretService
7882 Variables ports.VariableService
7983 Environments ports.EnvironmentService
84+ // OIDCKeys rotates the key of the ID tokens of CI jobs; nil when the server
85+ // issues none (GetVersion then does not list "ci.oidc"). Same rule as
86+ // Sessions about typed nil pointers.
87+ OIDCKeys ports.OIDCKeyService
8088 }
8189
8290 // API is the set of Connect handlers of the contract.
@@ -111,7 +119,10 @@ func New(cfg Config) *API {
111119 if settings {
112120 features = append(features, FeatureCISecrets)
113121 }
114- mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey}, opts...))
122+ if cfg.OIDCKeys != nil {
123+ features = append(features, FeatureCIOIDC)
124+ }
125+ mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...))
115126 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
116127 if cfg.Sessions != nil {
117128 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
@@ -39,6 +39,10 @@ var Features = []string{"repo.update", "events.resume", "merge_requests", "merge
39 // FeaturePasskeys is listed by GetVersion when passkeys are available.39 // FeaturePasskeys is listed by GetVersion when passkeys are available.
40 const FeaturePasskeys = "passkeys"40 const FeaturePasskeys = "passkeys"
41 41
42+// FeatureCIOIDC is listed by GetVersion when the server issues OIDC ID tokens
43+// to CI jobs (Job.id_token_url, ServerService.RotateOIDCKey).
44+const FeatureCIOIDC = "ci.oidc"
45+
42 // Config configures the API.46 // Config configures the API.
43 type Config struct {47 type Config struct {
44 Auth ports.AuthService48 Auth ports.AuthService
@@ -77,6 +81,10 @@ type Config struct {
77 Secrets ports.SecretService81 Secrets ports.SecretService
78 Variables ports.VariableService82 Variables ports.VariableService
79 Environments ports.EnvironmentService83 Environments ports.EnvironmentService
84+ // OIDCKeys rotates the key of the ID tokens of CI jobs; nil when the server
85+ // issues none (GetVersion then does not list "ci.oidc"). Same rule as
86+ // Sessions about typed nil pointers.
87+ OIDCKeys ports.OIDCKeyService
80 }88 }
81 89
82 // API is the set of Connect handlers of the contract.90 // API is the set of Connect handlers of the contract.
@@ -111,7 +119,10 @@ func New(cfg Config) *API {
111 if settings {119 if settings {
112 features = append(features, FeatureCISecrets)120 features = append(features, FeatureCISecrets)
113 }121 }
114- mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey}, opts...))122+ if cfg.OIDCKeys != nil {
123+ features = append(features, FeatureCIOIDC)
124+ }
125+ mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...))
115 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))126 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
116 if cfg.Sessions != nil {127 if cfg.Sessions != nil {
117 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))128 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
M internal/adapters/rpc/ci_convert.go +1 -0
@@ -225,6 +225,7 @@ func runnerJobToPB(j *ports.RunnerJob) *pb.Job {
225225 Secrets: j.Secrets, Vars: j.Vars, Environment: j.Environment, Trusted: j.Trusted,
226226 Permissions: &pb.JobPermissions{Levels: j.Permissions},
227227 Cache: &pb.JobCache{Repository: j.Cache.Repository, WriteRef: j.Cache.WriteRef, ReadRefs: j.Cache.ReadRefs},
228+ IdTokenUrl: j.IDTokenURL, IdTokenRequestToken: j.IDTokenRequestToken,
228229 }
229230 }
230231
@@ -225,6 +225,7 @@ func runnerJobToPB(j *ports.RunnerJob) *pb.Job {
225 Secrets: j.Secrets, Vars: j.Vars, Environment: j.Environment, Trusted: j.Trusted,225 Secrets: j.Secrets, Vars: j.Vars, Environment: j.Environment, Trusted: j.Trusted,
226 Permissions: &pb.JobPermissions{Levels: j.Permissions},226 Permissions: &pb.JobPermissions{Levels: j.Permissions},
227 Cache: &pb.JobCache{Repository: j.Cache.Repository, WriteRef: j.Cache.WriteRef, ReadRefs: j.Cache.ReadRefs},227 Cache: &pb.JobCache{Repository: j.Cache.Repository, WriteRef: j.Cache.WriteRef, ReadRefs: j.Cache.ReadRefs},
228+ IdTokenUrl: j.IDTokenURL, IdTokenRequestToken: j.IDTokenRequestToken,
228 }229 }
229 }230 }
230 231
M internal/adapters/rpc/server.go +25 -0
@@ -4,11 +4,13 @@ package rpc
44
55 import (
66 "context"
7+ "errors"
78 "slices"
89
910 "connectrpc.com/connect"
1011
1112 pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/domain"
1214 "bnema.dev/ermite/internal/ports"
1315 )
1416
@@ -17,6 +19,8 @@ type serverService struct {
1719 version string
1820 features []string
1921 key ports.SigningKeyService
22+ // oidc rotates the key of the ID tokens; nil when the server issues none.
23+ oidc ports.OIDCKeyService
2024 }
2125
2226 func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) {
@@ -35,3 +39,24 @@ func (s *serverService) GetSigningKey(context.Context, *connect.Request[pb.GetSi
3539 AllowedSigners: info.AllowedSigners(),
3640 }), nil
3741 }
42+
43+// RotateOIDCKey replaces the key that signs the ID tokens of CI jobs. Only the
44+// owner may; failed_precondition when the server issues no ID tokens.
45+func (s *serverService) RotateOIDCKey(ctx context.Context, _ *connect.Request[pb.RotateOIDCKeyRequest]) (*connect.Response[pb.RotateOIDCKeyResponse], error) {
46+ p := caller(ctx)
47+ if s.oidc == nil {
48+ if !p.HasOwnerRights() {
49+ return nil, connectError(ctx, domain.ErrForbidden)
50+ }
51+ return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY"))
52+ }
53+ r, err := s.oidc.RotateKey(ctx, p)
54+ if err != nil {
55+ return nil, connectError(ctx, err)
56+ }
57+ out := &pb.RotateOIDCKeyResponse{Issuer: r.Issuer, Kid: r.Kid, RetiredKid: r.RetiredKid}
58+ if r.RetiredKid != "" {
59+ out.RetiredUntil = timestamp(r.RetiredUntil)
60+ }
61+ return connect.NewResponse(out), nil
62+}
@@ -4,11 +4,13 @@ package rpc
4 4
5 import (5 import (
6 "context"6 "context"
7+ "errors"
7 "slices"8 "slices"
8 9
9 "connectrpc.com/connect"10 "connectrpc.com/connect"
10 11
11 pb "bnema.dev/ermite-api/gen/ermite"12 pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/domain"
12 "bnema.dev/ermite/internal/ports"14 "bnema.dev/ermite/internal/ports"
13 )15 )
14 16
@@ -17,6 +19,8 @@ type serverService struct {
17 version string19 version string
18 features []string20 features []string
19 key ports.SigningKeyService21 key ports.SigningKeyService
22+ // oidc rotates the key of the ID tokens; nil when the server issues none.
23+ oidc ports.OIDCKeyService
20 }24 }
21 25
22 func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) {26 func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) {
@@ -35,3 +39,24 @@ func (s *serverService) GetSigningKey(context.Context, *connect.Request[pb.GetSi
35 AllowedSigners: info.AllowedSigners(),39 AllowedSigners: info.AllowedSigners(),
36 }), nil40 }), nil
37 }41 }
42+
43+// RotateOIDCKey replaces the key that signs the ID tokens of CI jobs. Only the
44+// owner may; failed_precondition when the server issues no ID tokens.
45+func (s *serverService) RotateOIDCKey(ctx context.Context, _ *connect.Request[pb.RotateOIDCKeyRequest]) (*connect.Response[pb.RotateOIDCKeyResponse], error) {
46+ p := caller(ctx)
47+ if s.oidc == nil {
48+ if !p.HasOwnerRights() {
49+ return nil, connectError(ctx, domain.ErrForbidden)
50+ }
51+ return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY"))
52+ }
53+ r, err := s.oidc.RotateKey(ctx, p)
54+ if err != nil {
55+ return nil, connectError(ctx, err)
56+ }
57+ out := &pb.RotateOIDCKeyResponse{Issuer: r.Issuer, Kid: r.Kid, RetiredKid: r.RetiredKid}
58+ if r.RetiredKid != "" {
59+ out.RetiredUntil = timestamp(r.RetiredUntil)
60+ }
61+ return connect.NewResponse(out), nil
62+}
A internal/app/ci_oidc_test.go +325 -0
@@ -0,0 +1,325 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "bytes"
7+ "crypto"
8+ "crypto/rand"
9+ "crypto/rsa"
10+ "crypto/sha256"
11+ "encoding/base64"
12+ "encoding/json"
13+ "math/big"
14+ "net/http"
15+ "net/url"
16+ "strings"
17+ "sync"
18+ "testing"
19+ "time"
20+
21+ "connectrpc.com/connect"
22+ "github.com/stretchr/testify/assert"
23+ "github.com/stretchr/testify/require"
24+
25+ pb "bnema.dev/ermite-api/gen/ermite"
26+ "bnema.dev/ermite/internal/domain"
27+ "bnema.dev/ermite/internal/usecase"
28+)
29+
30+// CI-4 D5 / AC5: the OIDC ID tokens of CI jobs.
31+
32+// oidcKeys are the RSA keys the test servers sign with, generated once for the
33+// package: a 3072-bit key takes a while, more so under the race detector.
34+var oidcKeys struct {
35+ sync.Mutex
36+ keys []*rsa.PrivateKey
37+}
38+
39+// oidcTestKeys hands out the prepared keys in order, one per call, to one
40+// server.
41+func oidcTestKeys(t *testing.T) func() (*rsa.PrivateKey, error) {
42+ n := 0
43+ return func() (*rsa.PrivateKey, error) {
44+ oidcKeys.Lock()
45+ defer oidcKeys.Unlock()
46+ for len(oidcKeys.keys) <= n {
47+ k, err := rsa.GenerateKey(rand.Reader, usecase.OIDCKeyBits)
48+ if err != nil {
49+ return nil, err
50+ }
51+ oidcKeys.keys = append(oidcKeys.keys, k)
52+ }
53+ n++
54+ return oidcKeys.keys[n-1], nil
55+ }
56+}
57+
58+const oidcWorkflow = `on: push
59+jobs:
60+ oidc:
61+ runs-on: ubuntu-latest
62+ permissions:
63+ id-token: write
64+ contents: read
65+ steps:
66+ - run: echo oidc
67+ deploy:
68+ runs-on: ubuntu-latest
69+ environment: prod
70+ permissions:
71+ id-token: write
72+ steps:
73+ - run: echo deploy
74+ plain:
75+ runs-on: ubuntu-latest
76+ steps:
77+ - run: echo plain
78+`
79+
80+// idToken asks the token endpoint for an ID token with the bearer, as
81+// @actions/core does with ACTIONS_ID_TOKEN_REQUEST_URL (rebased on the test
82+// server) and "&audience=".
83+func (c *ciHarness) idToken(j *pb.Job, bearer, audience string) (int, string) {
84+ c.t.Helper()
85+ u, err := url.Parse(j.GetIdTokenUrl())
86+ require.NoError(c.t, err)
87+ path := u.RequestURI()
88+ if audience != "" {
89+ path += "&audience=" + url.QueryEscape(audience)
90+ }
91+ status, body, hdr := c.get(path, "Bearer "+bearer)
92+ if status != http.StatusOK {
93+ return status, body
94+ }
95+ assert.Equal(c.t, "no-store", hdr.Get("Cache-Control"))
96+ var res struct {
97+ Value string `json:"value"`
98+ }
99+ require.NoError(c.t, json.Unmarshal([]byte(body), &res))
100+ require.NotEmpty(c.t, res.Value)
101+ return status, res.Value
102+}
103+
104+// verifyIDToken checks the RS256 signature of token against the JWKS the
105+// server publishes, with the standard library only, and returns its header
106+// and claims.
107+func (c *ciHarness) verifyIDToken(token string) (header, claims map[string]any) {
108+ c.t.Helper()
109+ status, body, hdr := c.get("/-/oidc/.well-known/jwks", "")
110+ require.Equal(c.t, http.StatusOK, status, body)
111+ assert.Contains(c.t, hdr.Get("Cache-Control"), "public")
112+ var set struct {
113+ Keys []struct{ Kty, Kid, N, E string } `json:"keys"`
114+ }
115+ require.NoError(c.t, json.Unmarshal([]byte(body), &set))
116+ parts := strings.Split(token, ".")
117+ require.Len(c.t, parts, 3)
118+ decode := func(s string, v any) {
119+ raw, err := base64.RawURLEncoding.DecodeString(s)
120+ require.NoError(c.t, err)
121+ require.NoError(c.t, json.Unmarshal(raw, v))
122+ }
123+ decode(parts[0], &header)
124+ decode(parts[1], &claims)
125+ require.Equal(c.t, "RS256", header["alg"])
126+ var pub *rsa.PublicKey
127+ for _, k := range set.Keys {
128+ if k.Kid != header["kid"] {
129+ continue
130+ }
131+ n, err := base64.RawURLEncoding.DecodeString(k.N)
132+ require.NoError(c.t, err)
133+ e, err := base64.RawURLEncoding.DecodeString(k.E)
134+ require.NoError(c.t, err)
135+ pub = &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())}
136+ }
137+ require.NotNil(c.t, pub, "the kid of the token is in the JWKS")
138+ sig, err := base64.RawURLEncoding.DecodeString(parts[2])
139+ require.NoError(c.t, err)
140+ sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
141+ require.NoError(c.t, rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig), "the signature verifies against the JWKS")
142+ return header, claims
143+}
144+
145+func TestOIDCIDTokens(t *testing.T) {
146+ c := newSecretsHarness(t, secretsKeyA)
147+ runner := c.registerRunner("alpha", "ubuntu-latest")
148+ sha := c.push("main", map[string]string{".github/workflows/o.yml": oidcWorkflow}, "")
149+ jobs := runner.jobsByKey(3)
150+ job, deploy, plain := jobs["oidc"], jobs["deploy"], jobs["plain"]
151+
152+ // Only the jobs with id-token: write get the endpoint and a request token,
153+ // and the token is nowhere in what the runner stores.
154+ assert.Equal(t, "http://example.test/-/oidc/token?api-version=2.0", job.GetIdTokenUrl())
155+ assert.True(t, domain.IsOIDCRequestToken(job.GetIdTokenRequestToken()))
156+ assert.NotEqual(t, job.GetIdTokenRequestToken(), deploy.GetIdTokenRequestToken())
157+ assert.NotContains(t, job.GetContexts(), job.GetIdTokenRequestToken())
158+ assert.Empty(t, plain.GetIdTokenUrl(), "no id-token: write")
159+ assert.Empty(t, plain.GetIdTokenRequestToken())
160+ ver, err := c.root.server.GetVersion(c.ctx, connect.NewRequest(&pb.GetVersionRequest{}))
161+ require.NoError(t, err)
162+ assert.Contains(t, ver.Msg.GetFeatures(), "ci.oidc")
163+
164+ // Discovery, anonymous and cacheable.
165+ status, body, hdr := c.get("/-/oidc/.well-known/openid-configuration", "")
166+ require.Equal(t, http.StatusOK, status, body)
167+ assert.Contains(t, hdr.Get("Cache-Control"), "max-age")
168+ var disc map[string]any
169+ require.NoError(t, json.Unmarshal([]byte(body), &disc))
170+ assert.Equal(t, "http://example.test/-/oidc", disc["issuer"])
171+ assert.Equal(t, "http://example.test/-/oidc/.well-known/jwks", disc["jwks_uri"])
172+ assert.Equal(t, []any{"RS256"}, disc["id_token_signing_alg_values_supported"])
173+
174+ // The default audience, and the claims of a push.
175+ before := time.Now().Unix()
176+ status, tok := c.idToken(job, job.GetIdTokenRequestToken(), "")
177+ require.Equal(t, http.StatusOK, status, tok)
178+ header, claims := c.verifyIDToken(tok)
179+ assert.Equal(t, "JWT", header["typ"])
180+ assert.Equal(t, "http://example.test/-/oidc", claims["iss"])
181+ assert.Equal(t, "http://example.test/owner", claims["aud"])
182+ assert.Equal(t, "repo:owner/proj:ref:refs/heads/main", claims["sub"])
183+ assert.Equal(t, "refs/heads/main", claims["ref"])
184+ assert.Equal(t, "branch", claims["ref_type"])
185+ assert.Equal(t, sha, claims["sha"])
186+ assert.Equal(t, "owner/proj", claims["repository"])
187+ assert.Equal(t, "owner", claims["repository_owner"])
188+ assert.Equal(t, "private", claims["repository_visibility"])
189+ assert.Equal(t, "push", claims["event_name"])
190+ assert.Equal(t, "1", claims["run_attempt"])
191+ assert.Equal(t, "self-hosted", claims["runner_environment"])
192+ assert.Equal(t, "owner/proj/.github/workflows/o.yml@refs/heads/main", claims["job_workflow_ref"])
193+ assert.Equal(t, sha, claims["job_workflow_sha"])
194+ assert.NotEmpty(t, claims["jti"])
195+ assert.NotContains(t, claims, "environment")
196+ iat := int64(claims["iat"].(float64))
197+ assert.GreaterOrEqual(t, iat, before)
198+ assert.Equal(t, iat-600, int64(claims["nbf"].(float64)))
199+ assert.Equal(t, iat+300, int64(claims["exp"].(float64)))
200+
201+ // A requested audience; every token has its own jti.
202+ status, tok2 := c.idToken(job, job.GetIdTokenRequestToken(), "sts.amazonaws.com")
203+ require.Equal(t, http.StatusOK, status, tok2)
204+ _, claims2 := c.verifyIDToken(tok2)
205+ assert.Equal(t, "sts.amazonaws.com", claims2["aud"])
206+ assert.NotEqual(t, claims["jti"], claims2["jti"])
207+ status, _ = c.idToken(job, job.GetIdTokenRequestToken(), strings.Repeat("a", domain.MaxOIDCAudienceLen+1))
208+ assert.Equal(t, http.StatusBadRequest, status)
209+
210+ // The environment of a job is its subject.
211+ status, tok3 := c.idToken(deploy, deploy.GetIdTokenRequestToken(), "")
212+ require.Equal(t, http.StatusOK, status, tok3)
213+ _, claims3 := c.verifyIDToken(tok3)
214+ assert.Equal(t, "repo:owner/proj:environment:prod", claims3["sub"])
215+ assert.Equal(t, "prod", claims3["environment"])
216+
217+ // No other secret gets a token: the job's repository token, its job token,
218+ // a token of the right shape that does not exist, nothing.
219+ for name, bearer := range map[string]string{
220+ "github token": job.GetGithubToken(), "job token": job.GetJobToken(), "admin token": adminToken,
221+ "unknown": domain.OIDCRequestTokenPrefix + strings.Repeat("A", domain.CITokenRandomLen),
222+ } {
223+ status, _ := c.idToken(job, bearer, "")
224+ assert.Equal(t, http.StatusUnauthorized, status, name)
225+ }
226+ status, _, _ = c.get("/-/oidc/token?api-version=2.0", "")
227+ assert.Equal(t, http.StatusUnauthorized, status, "no credentials")
228+
229+ // The request token authenticates nothing else: not the API, not Git.
230+ status, _, _ = c.get("/-/github/api/repos/owner/proj", "Bearer "+job.GetIdTokenRequestToken())
231+ assert.Equal(t, http.StatusUnauthorized, status)
232+ out, err := c.clone(job.GetIdTokenRequestToken(), c.url("/owner/proj"))
233+ require.Error(t, err, out)
234+
235+ // The key is stored sealed: the raw value is not a private key.
236+ kid, _ := header["kid"].(string)
237+ raw, ok := c.forge.store.RawOIDCKey(c.ctx, kid)
238+ require.True(t, ok)
239+ assert.NotContains(t, string(raw), "PRIVATE KEY", "not a PEM")
240+ oidcKeys.Lock()
241+ for _, k := range oidcKeys.keys {
242+ assert.False(t, bytes.Contains(raw, k.D.Bytes()), "no private exponent in the clear")
243+ }
244+ oidcKeys.Unlock()
245+
246+ // Once the job ended, its request token is refused, while another job's
247+ // still works.
248+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
249+ status, _ = c.idToken(job, job.GetIdTokenRequestToken(), "")
250+ assert.Equal(t, http.StatusUnauthorized, status, "the job ended")
251+ status, _ = c.idToken(deploy, deploy.GetIdTokenRequestToken(), "")
252+ assert.Equal(t, http.StatusOK, status)
253+}
254+
255+// A run that is not trusted (pushed by a token limited to branches) gets no ID
256+// tokens, whatever its permissions say.
257+func TestOIDCUntrustedRunGetsNoToken(t *testing.T) {
258+ c := newSecretsHarness(t, secretsKeyA)
259+ runner := c.registerRunner("alpha", "ubuntu-latest")
260+ c.push("main", map[string]string{"README.md": "hi"}, "")
261+ secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/"}, "proj")
262+ c.pushAs(secret, "agent/x", map[string]string{".github/workflows/o.yml": oidcWorkflow})
263+ for key, j := range runner.jobsByKey(3) {
264+ assert.False(t, j.GetTrusted(), key)
265+ assert.Empty(t, j.GetIdTokenUrl(), key)
266+ assert.Empty(t, j.GetIdTokenRequestToken(), key)
267+ }
268+}
269+
270+// Without ERMITE_SECRETS_KEY there is no OIDC: no variables, no provider, no
271+// rotation.
272+func TestOIDCWithoutSecretsKey(t *testing.T) {
273+ c := newSecretsHarness(t, "")
274+ runner := c.registerRunner("alpha", "ubuntu-latest")
275+ c.push("main", map[string]string{".github/workflows/o.yml": oidcWorkflow}, "")
276+ for key, j := range runner.jobsByKey(3) {
277+ assert.Empty(t, j.GetIdTokenUrl(), key)
278+ assert.Empty(t, j.GetIdTokenRequestToken(), key)
279+ }
280+ for _, path := range []string{"/-/oidc/.well-known/openid-configuration", "/-/oidc/.well-known/jwks", "/-/oidc/token?api-version=2.0"} {
281+ status, _, _ := c.get(path, "")
282+ assert.Equal(t, http.StatusNotFound, status, path)
283+ }
284+ _, err := c.root.server.RotateOIDCKey(c.ctx, connect.NewRequest(&pb.RotateOIDCKeyRequest{}))
285+ assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err))
286+ ver, err := c.root.server.GetVersion(c.ctx, connect.NewRequest(&pb.GetVersionRequest{}))
287+ require.NoError(t, err)
288+ assert.NotContains(t, ver.Msg.GetFeatures(), "ci.oidc")
289+}
290+
291+// `ermite oidc rotate-key`: owner only; the new key signs at once and the old
292+// one still verifies the tokens it signed.
293+func TestOIDCRotateKey(t *testing.T) {
294+ c := newSecretsHarness(t, secretsKeyA)
295+ runner := c.registerRunner("alpha", "ubuntu-latest")
296+ c.push("main", map[string]string{".github/workflows/o.yml": oidcWorkflow}, "")
297+ job := runner.jobsByKey(3)["oidc"]
298+ status, before := c.idToken(job, job.GetIdTokenRequestToken(), "")
299+ require.Equal(t, http.StatusOK, status)
300+ oldHeader, _ := c.verifyIDToken(before)
301+
302+ limited := c.api(c.token(scopes(domain.ScopeRepoRead, domain.ScopeCIWrite), "proj"))
303+ _, err := limited.server.RotateOIDCKey(c.ctx, connect.NewRequest(&pb.RotateOIDCKeyRequest{}))
304+ assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
305+
306+ res := runCLI(t, map[string]string{"ERMITE_URL": c.srv.URL, "ERMITE_TOKEN": adminToken}, "", "oidc", "rotate-key", "--json").ok(t)
307+ var rot struct {
308+ Issuer string `json:"issuer"`
309+ Kid string `json:"kid"`
310+ RetiredKid *string `json:"retired_kid"`
311+ RetiredUntil *string `json:"retired_until"`
312+ }
313+ require.NoError(t, json.Unmarshal([]byte(res.stdout), &rot), res.stdout)
314+ assert.Equal(t, "http://example.test/-/oidc", rot.Issuer)
315+ require.NotNil(t, rot.RetiredKid)
316+ assert.Equal(t, oldHeader["kid"], *rot.RetiredKid)
317+ assert.NotEqual(t, *rot.RetiredKid, rot.Kid)
318+ require.NotNil(t, rot.RetiredUntil)
319+
320+ c.verifyIDToken(before) // the old key is still published
321+ status, after := c.idToken(job, job.GetIdTokenRequestToken(), "")
322+ require.Equal(t, http.StatusOK, status)
323+ newHeader, _ := c.verifyIDToken(after)
324+ assert.Equal(t, rot.Kid, newHeader["kid"])
325+}
@@ -0,0 +1,325 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "bytes"
7+ "crypto"
8+ "crypto/rand"
9+ "crypto/rsa"
10+ "crypto/sha256"
11+ "encoding/base64"
12+ "encoding/json"
13+ "math/big"
14+ "net/http"
15+ "net/url"
16+ "strings"
17+ "sync"
18+ "testing"
19+ "time"
20+
21+ "connectrpc.com/connect"
22+ "github.com/stretchr/testify/assert"
23+ "github.com/stretchr/testify/require"
24+
25+ pb "bnema.dev/ermite-api/gen/ermite"
26+ "bnema.dev/ermite/internal/domain"
27+ "bnema.dev/ermite/internal/usecase"
28+)
29+
30+// CI-4 D5 / AC5: the OIDC ID tokens of CI jobs.
31+
32+// oidcKeys are the RSA keys the test servers sign with, generated once for the
33+// package: a 3072-bit key takes a while, more so under the race detector.
34+var oidcKeys struct {
35+ sync.Mutex
36+ keys []*rsa.PrivateKey
37+}
38+
39+// oidcTestKeys hands out the prepared keys in order, one per call, to one
40+// server.
41+func oidcTestKeys(t *testing.T) func() (*rsa.PrivateKey, error) {
42+ n := 0
43+ return func() (*rsa.PrivateKey, error) {
44+ oidcKeys.Lock()
45+ defer oidcKeys.Unlock()
46+ for len(oidcKeys.keys) <= n {
47+ k, err := rsa.GenerateKey(rand.Reader, usecase.OIDCKeyBits)
48+ if err != nil {
49+ return nil, err
50+ }
51+ oidcKeys.keys = append(oidcKeys.keys, k)
52+ }
53+ n++
54+ return oidcKeys.keys[n-1], nil
55+ }
56+}
57+
58+const oidcWorkflow = `on: push
59+jobs:
60+ oidc:
61+ runs-on: ubuntu-latest
62+ permissions:
63+ id-token: write
64+ contents: read
65+ steps:
66+ - run: echo oidc
67+ deploy:
68+ runs-on: ubuntu-latest
69+ environment: prod
70+ permissions:
71+ id-token: write
72+ steps:
73+ - run: echo deploy
74+ plain:
75+ runs-on: ubuntu-latest
76+ steps:
77+ - run: echo plain
78+`
79+
80+// idToken asks the token endpoint for an ID token with the bearer, as
81+// @actions/core does with ACTIONS_ID_TOKEN_REQUEST_URL (rebased on the test
82+// server) and "&audience=".
83+func (c *ciHarness) idToken(j *pb.Job, bearer, audience string) (int, string) {
84+ c.t.Helper()
85+ u, err := url.Parse(j.GetIdTokenUrl())
86+ require.NoError(c.t, err)
87+ path := u.RequestURI()
88+ if audience != "" {
89+ path += "&audience=" + url.QueryEscape(audience)
90+ }
91+ status, body, hdr := c.get(path, "Bearer "+bearer)
92+ if status != http.StatusOK {
93+ return status, body
94+ }
95+ assert.Equal(c.t, "no-store", hdr.Get("Cache-Control"))
96+ var res struct {
97+ Value string `json:"value"`
98+ }
99+ require.NoError(c.t, json.Unmarshal([]byte(body), &res))
100+ require.NotEmpty(c.t, res.Value)
101+ return status, res.Value
102+}
103+
104+// verifyIDToken checks the RS256 signature of token against the JWKS the
105+// server publishes, with the standard library only, and returns its header
106+// and claims.
107+func (c *ciHarness) verifyIDToken(token string) (header, claims map[string]any) {
108+ c.t.Helper()
109+ status, body, hdr := c.get("/-/oidc/.well-known/jwks", "")
110+ require.Equal(c.t, http.StatusOK, status, body)
111+ assert.Contains(c.t, hdr.Get("Cache-Control"), "public")
112+ var set struct {
113+ Keys []struct{ Kty, Kid, N, E string } `json:"keys"`
114+ }
115+ require.NoError(c.t, json.Unmarshal([]byte(body), &set))
116+ parts := strings.Split(token, ".")
117+ require.Len(c.t, parts, 3)
118+ decode := func(s string, v any) {
119+ raw, err := base64.RawURLEncoding.DecodeString(s)
120+ require.NoError(c.t, err)
121+ require.NoError(c.t, json.Unmarshal(raw, v))
122+ }
123+ decode(parts[0], &header)
124+ decode(parts[1], &claims)
125+ require.Equal(c.t, "RS256", header["alg"])
126+ var pub *rsa.PublicKey
127+ for _, k := range set.Keys {
128+ if k.Kid != header["kid"] {
129+ continue
130+ }
131+ n, err := base64.RawURLEncoding.DecodeString(k.N)
132+ require.NoError(c.t, err)
133+ e, err := base64.RawURLEncoding.DecodeString(k.E)
134+ require.NoError(c.t, err)
135+ pub = &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())}
136+ }
137+ require.NotNil(c.t, pub, "the kid of the token is in the JWKS")
138+ sig, err := base64.RawURLEncoding.DecodeString(parts[2])
139+ require.NoError(c.t, err)
140+ sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
141+ require.NoError(c.t, rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig), "the signature verifies against the JWKS")
142+ return header, claims
143+}
144+
145+func TestOIDCIDTokens(t *testing.T) {
146+ c := newSecretsHarness(t, secretsKeyA)
147+ runner := c.registerRunner("alpha", "ubuntu-latest")
148+ sha := c.push("main", map[string]string{".github/workflows/o.yml": oidcWorkflow}, "")
149+ jobs := runner.jobsByKey(3)
150+ job, deploy, plain := jobs["oidc"], jobs["deploy"], jobs["plain"]
151+
152+ // Only the jobs with id-token: write get the endpoint and a request token,
153+ // and the token is nowhere in what the runner stores.
154+ assert.Equal(t, "http://example.test/-/oidc/token?api-version=2.0", job.GetIdTokenUrl())
155+ assert.True(t, domain.IsOIDCRequestToken(job.GetIdTokenRequestToken()))
156+ assert.NotEqual(t, job.GetIdTokenRequestToken(), deploy.GetIdTokenRequestToken())
157+ assert.NotContains(t, job.GetContexts(), job.GetIdTokenRequestToken())
158+ assert.Empty(t, plain.GetIdTokenUrl(), "no id-token: write")
159+ assert.Empty(t, plain.GetIdTokenRequestToken())
160+ ver, err := c.root.server.GetVersion(c.ctx, connect.NewRequest(&pb.GetVersionRequest{}))
161+ require.NoError(t, err)
162+ assert.Contains(t, ver.Msg.GetFeatures(), "ci.oidc")
163+
164+ // Discovery, anonymous and cacheable.
165+ status, body, hdr := c.get("/-/oidc/.well-known/openid-configuration", "")
166+ require.Equal(t, http.StatusOK, status, body)
167+ assert.Contains(t, hdr.Get("Cache-Control"), "max-age")
168+ var disc map[string]any
169+ require.NoError(t, json.Unmarshal([]byte(body), &disc))
170+ assert.Equal(t, "http://example.test/-/oidc", disc["issuer"])
171+ assert.Equal(t, "http://example.test/-/oidc/.well-known/jwks", disc["jwks_uri"])
172+ assert.Equal(t, []any{"RS256"}, disc["id_token_signing_alg_values_supported"])
173+
174+ // The default audience, and the claims of a push.
175+ before := time.Now().Unix()
176+ status, tok := c.idToken(job, job.GetIdTokenRequestToken(), "")
177+ require.Equal(t, http.StatusOK, status, tok)
178+ header, claims := c.verifyIDToken(tok)
179+ assert.Equal(t, "JWT", header["typ"])
180+ assert.Equal(t, "http://example.test/-/oidc", claims["iss"])
181+ assert.Equal(t, "http://example.test/owner", claims["aud"])
182+ assert.Equal(t, "repo:owner/proj:ref:refs/heads/main", claims["sub"])
183+ assert.Equal(t, "refs/heads/main", claims["ref"])
184+ assert.Equal(t, "branch", claims["ref_type"])
185+ assert.Equal(t, sha, claims["sha"])
186+ assert.Equal(t, "owner/proj", claims["repository"])
187+ assert.Equal(t, "owner", claims["repository_owner"])
188+ assert.Equal(t, "private", claims["repository_visibility"])
189+ assert.Equal(t, "push", claims["event_name"])
190+ assert.Equal(t, "1", claims["run_attempt"])
191+ assert.Equal(t, "self-hosted", claims["runner_environment"])
192+ assert.Equal(t, "owner/proj/.github/workflows/o.yml@refs/heads/main", claims["job_workflow_ref"])
193+ assert.Equal(t, sha, claims["job_workflow_sha"])
194+ assert.NotEmpty(t, claims["jti"])
195+ assert.NotContains(t, claims, "environment")
196+ iat := int64(claims["iat"].(float64))
197+ assert.GreaterOrEqual(t, iat, before)
198+ assert.Equal(t, iat-600, int64(claims["nbf"].(float64)))
199+ assert.Equal(t, iat+300, int64(claims["exp"].(float64)))
200+
201+ // A requested audience; every token has its own jti.
202+ status, tok2 := c.idToken(job, job.GetIdTokenRequestToken(), "sts.amazonaws.com")
203+ require.Equal(t, http.StatusOK, status, tok2)
204+ _, claims2 := c.verifyIDToken(tok2)
205+ assert.Equal(t, "sts.amazonaws.com", claims2["aud"])
206+ assert.NotEqual(t, claims["jti"], claims2["jti"])
207+ status, _ = c.idToken(job, job.GetIdTokenRequestToken(), strings.Repeat("a", domain.MaxOIDCAudienceLen+1))
208+ assert.Equal(t, http.StatusBadRequest, status)
209+
210+ // The environment of a job is its subject.
211+ status, tok3 := c.idToken(deploy, deploy.GetIdTokenRequestToken(), "")
212+ require.Equal(t, http.StatusOK, status, tok3)
213+ _, claims3 := c.verifyIDToken(tok3)
214+ assert.Equal(t, "repo:owner/proj:environment:prod", claims3["sub"])
215+ assert.Equal(t, "prod", claims3["environment"])
216+
217+ // No other secret gets a token: the job's repository token, its job token,
218+ // a token of the right shape that does not exist, nothing.
219+ for name, bearer := range map[string]string{
220+ "github token": job.GetGithubToken(), "job token": job.GetJobToken(), "admin token": adminToken,
221+ "unknown": domain.OIDCRequestTokenPrefix + strings.Repeat("A", domain.CITokenRandomLen),
222+ } {
223+ status, _ := c.idToken(job, bearer, "")
224+ assert.Equal(t, http.StatusUnauthorized, status, name)
225+ }
226+ status, _, _ = c.get("/-/oidc/token?api-version=2.0", "")
227+ assert.Equal(t, http.StatusUnauthorized, status, "no credentials")
228+
229+ // The request token authenticates nothing else: not the API, not Git.
230+ status, _, _ = c.get("/-/github/api/repos/owner/proj", "Bearer "+job.GetIdTokenRequestToken())
231+ assert.Equal(t, http.StatusUnauthorized, status)
232+ out, err := c.clone(job.GetIdTokenRequestToken(), c.url("/owner/proj"))
233+ require.Error(t, err, out)
234+
235+ // The key is stored sealed: the raw value is not a private key.
236+ kid, _ := header["kid"].(string)
237+ raw, ok := c.forge.store.RawOIDCKey(c.ctx, kid)
238+ require.True(t, ok)
239+ assert.NotContains(t, string(raw), "PRIVATE KEY", "not a PEM")
240+ oidcKeys.Lock()
241+ for _, k := range oidcKeys.keys {
242+ assert.False(t, bytes.Contains(raw, k.D.Bytes()), "no private exponent in the clear")
243+ }
244+ oidcKeys.Unlock()
245+
246+ // Once the job ended, its request token is refused, while another job's
247+ // still works.
248+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
249+ status, _ = c.idToken(job, job.GetIdTokenRequestToken(), "")
250+ assert.Equal(t, http.StatusUnauthorized, status, "the job ended")
251+ status, _ = c.idToken(deploy, deploy.GetIdTokenRequestToken(), "")
252+ assert.Equal(t, http.StatusOK, status)
253+}
254+
255+// A run that is not trusted (pushed by a token limited to branches) gets no ID
256+// tokens, whatever its permissions say.
257+func TestOIDCUntrustedRunGetsNoToken(t *testing.T) {
258+ c := newSecretsHarness(t, secretsKeyA)
259+ runner := c.registerRunner("alpha", "ubuntu-latest")
260+ c.push("main", map[string]string{"README.md": "hi"}, "")
261+ secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/"}, "proj")
262+ c.pushAs(secret, "agent/x", map[string]string{".github/workflows/o.yml": oidcWorkflow})
263+ for key, j := range runner.jobsByKey(3) {
264+ assert.False(t, j.GetTrusted(), key)
265+ assert.Empty(t, j.GetIdTokenUrl(), key)
266+ assert.Empty(t, j.GetIdTokenRequestToken(), key)
267+ }
268+}
269+
270+// Without ERMITE_SECRETS_KEY there is no OIDC: no variables, no provider, no
271+// rotation.
272+func TestOIDCWithoutSecretsKey(t *testing.T) {
273+ c := newSecretsHarness(t, "")
274+ runner := c.registerRunner("alpha", "ubuntu-latest")
275+ c.push("main", map[string]string{".github/workflows/o.yml": oidcWorkflow}, "")
276+ for key, j := range runner.jobsByKey(3) {
277+ assert.Empty(t, j.GetIdTokenUrl(), key)
278+ assert.Empty(t, j.GetIdTokenRequestToken(), key)
279+ }
280+ for _, path := range []string{"/-/oidc/.well-known/openid-configuration", "/-/oidc/.well-known/jwks", "/-/oidc/token?api-version=2.0"} {
281+ status, _, _ := c.get(path, "")
282+ assert.Equal(t, http.StatusNotFound, status, path)
283+ }
284+ _, err := c.root.server.RotateOIDCKey(c.ctx, connect.NewRequest(&pb.RotateOIDCKeyRequest{}))
285+ assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err))
286+ ver, err := c.root.server.GetVersion(c.ctx, connect.NewRequest(&pb.GetVersionRequest{}))
287+ require.NoError(t, err)
288+ assert.NotContains(t, ver.Msg.GetFeatures(), "ci.oidc")
289+}
290+
291+// `ermite oidc rotate-key`: owner only; the new key signs at once and the old
292+// one still verifies the tokens it signed.
293+func TestOIDCRotateKey(t *testing.T) {
294+ c := newSecretsHarness(t, secretsKeyA)
295+ runner := c.registerRunner("alpha", "ubuntu-latest")
296+ c.push("main", map[string]string{".github/workflows/o.yml": oidcWorkflow}, "")
297+ job := runner.jobsByKey(3)["oidc"]
298+ status, before := c.idToken(job, job.GetIdTokenRequestToken(), "")
299+ require.Equal(t, http.StatusOK, status)
300+ oldHeader, _ := c.verifyIDToken(before)
301+
302+ limited := c.api(c.token(scopes(domain.ScopeRepoRead, domain.ScopeCIWrite), "proj"))
303+ _, err := limited.server.RotateOIDCKey(c.ctx, connect.NewRequest(&pb.RotateOIDCKeyRequest{}))
304+ assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
305+
306+ res := runCLI(t, map[string]string{"ERMITE_URL": c.srv.URL, "ERMITE_TOKEN": adminToken}, "", "oidc", "rotate-key", "--json").ok(t)
307+ var rot struct {
308+ Issuer string `json:"issuer"`
309+ Kid string `json:"kid"`
310+ RetiredKid *string `json:"retired_kid"`
311+ RetiredUntil *string `json:"retired_until"`
312+ }
313+ require.NoError(t, json.Unmarshal([]byte(res.stdout), &rot), res.stdout)
314+ assert.Equal(t, "http://example.test/-/oidc", rot.Issuer)
315+ require.NotNil(t, rot.RetiredKid)
316+ assert.Equal(t, oldHeader["kid"], *rot.RetiredKid)
317+ assert.NotEqual(t, *rot.RetiredKid, rot.Kid)
318+ require.NotNil(t, rot.RetiredUntil)
319+
320+ c.verifyIDToken(before) // the old key is still published
321+ status, after := c.idToken(job, job.GetIdTokenRequestToken(), "")
322+ require.Equal(t, http.StatusOK, status)
323+ newHeader, _ := c.verifyIDToken(after)
324+ assert.Equal(t, rot.Kid, newHeader["kid"])
325+}
A internal/app/cli_oidc.go +72 -0
@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "connectrpc.com/connect"
7+ "github.com/spf13/cobra"
8+
9+ "bnema.dev/ermite-api/gen/ermite"
10+)
11+
12+// jsonOIDCRotation is the stable JSON shape of `oidc rotate-key --json`.
13+type jsonOIDCRotation struct {
14+ Issuer string `json:"issuer"`
15+ Kid string `json:"kid"`
16+ RetiredKid *string `json:"retired_kid"`
17+ RetiredUntil *string `json:"retired_until"`
18+}
19+
20+func newOIDCCmd(env *cliEnv) *cobra.Command {
21+ oidc := &cobra.Command{
22+ Use: "oidc",
23+ Short: "Manage the OIDC provider of CI jobs",
24+ Long: "The server issues OIDC ID tokens to the CI jobs of trusted runs that have `permissions: id-token:\n" +
25+ "write`, as GitHub does: a relying party (Vault, a cloud) that trusts the issuer\n" +
26+ "<public URL>/-/oidc exchanges them for credentials. It signs them with its own key, sealed with\n" +
27+ "ERMITE_SECRETS_KEY: a server without that key issues none. Needs a server with the ci.oidc\n" +
28+ "feature (see `ermite version`).",
29+ }
30+ oidc.AddCommand(newOIDCRotateKeyCmd(env))
31+ return oidc
32+}
33+
34+func newOIDCRotateKeyCmd(env *cliEnv) *cobra.Command {
35+ cmd := &cobra.Command{
36+ Use: "rotate-key",
37+ Short: "Replace the key that signs the ID tokens",
38+ Long: "Generate a new signing key for the ID tokens. The new key signs at once; the previous one stays\n" +
39+ "in the JWKS (<issuer>/.well-known/jwks) for 24 hours, so that the tokens it signed verify until they\n" +
40+ "expire. Needs owner rights: the admin token, or a token with every scope, no repository list\n" +
41+ "and no branch prefixes.\n\n" +
42+ "JSON fields (--json): issuer, kid, retired_kid and retired_until (RFC 3339 UTC; both null when\n" +
43+ "there was no previous key).",
44+ Args: cobra.NoArgs,
45+ }
46+ asJSON := addJSONFlag(cmd)
47+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
48+ c, err := env.client(cmd)
49+ if err != nil {
50+ return err
51+ }
52+ res, err := c.Server().RotateOIDCKey(env.ctx(cmd), connect.NewRequest(&ermite.RotateOIDCKeyRequest{}))
53+ if err != nil {
54+ return c.Describe(err)
55+ }
56+ r := res.Msg
57+ out := cmd.OutOrStdout()
58+ if *asJSON {
59+ j := jsonOIDCRotation{Issuer: r.GetIssuer(), Kid: r.GetKid(), RetiredUntil: jsonTime(r.GetRetiredUntil())}
60+ if k := r.GetRetiredKid(); k != "" {
61+ j.RetiredKid = &k
62+ }
63+ return writeJSON(out, j)
64+ }
65+ pairs := [][2]string{{"issuer", r.GetIssuer()}, {"kid", r.GetKid()}}
66+ if r.GetRetiredKid() != "" {
67+ pairs = append(pairs, [2]string{"retired kid", r.GetRetiredKid()}, [2]string{"published until", shortTime(r.GetRetiredUntil(), "")})
68+ }
69+ return fields(out, pairs)
70+ }
71+ return cmd
72+}
@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "connectrpc.com/connect"
7+ "github.com/spf13/cobra"
8+
9+ "bnema.dev/ermite-api/gen/ermite"
10+)
11+
12+// jsonOIDCRotation is the stable JSON shape of `oidc rotate-key --json`.
13+type jsonOIDCRotation struct {
14+ Issuer string `json:"issuer"`
15+ Kid string `json:"kid"`
16+ RetiredKid *string `json:"retired_kid"`
17+ RetiredUntil *string `json:"retired_until"`
18+}
19+
20+func newOIDCCmd(env *cliEnv) *cobra.Command {
21+ oidc := &cobra.Command{
22+ Use: "oidc",
23+ Short: "Manage the OIDC provider of CI jobs",
24+ Long: "The server issues OIDC ID tokens to the CI jobs of trusted runs that have `permissions: id-token:\n" +
25+ "write`, as GitHub does: a relying party (Vault, a cloud) that trusts the issuer\n" +
26+ "<public URL>/-/oidc exchanges them for credentials. It signs them with its own key, sealed with\n" +
27+ "ERMITE_SECRETS_KEY: a server without that key issues none. Needs a server with the ci.oidc\n" +
28+ "feature (see `ermite version`).",
29+ }
30+ oidc.AddCommand(newOIDCRotateKeyCmd(env))
31+ return oidc
32+}
33+
34+func newOIDCRotateKeyCmd(env *cliEnv) *cobra.Command {
35+ cmd := &cobra.Command{
36+ Use: "rotate-key",
37+ Short: "Replace the key that signs the ID tokens",
38+ Long: "Generate a new signing key for the ID tokens. The new key signs at once; the previous one stays\n" +
39+ "in the JWKS (<issuer>/.well-known/jwks) for 24 hours, so that the tokens it signed verify until they\n" +
40+ "expire. Needs owner rights: the admin token, or a token with every scope, no repository list\n" +
41+ "and no branch prefixes.\n\n" +
42+ "JSON fields (--json): issuer, kid, retired_kid and retired_until (RFC 3339 UTC; both null when\n" +
43+ "there was no previous key).",
44+ Args: cobra.NoArgs,
45+ }
46+ asJSON := addJSONFlag(cmd)
47+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
48+ c, err := env.client(cmd)
49+ if err != nil {
50+ return err
51+ }
52+ res, err := c.Server().RotateOIDCKey(env.ctx(cmd), connect.NewRequest(&ermite.RotateOIDCKeyRequest{}))
53+ if err != nil {
54+ return c.Describe(err)
55+ }
56+ r := res.Msg
57+ out := cmd.OutOrStdout()
58+ if *asJSON {
59+ j := jsonOIDCRotation{Issuer: r.GetIssuer(), Kid: r.GetKid(), RetiredUntil: jsonTime(r.GetRetiredUntil())}
60+ if k := r.GetRetiredKid(); k != "" {
61+ j.RetiredKid = &k
62+ }
63+ return writeJSON(out, j)
64+ }
65+ pairs := [][2]string{{"issuer", r.GetIssuer()}, {"kid", r.GetKid()}}
66+ if r.GetRetiredKid() != "" {
67+ pairs = append(pairs, [2]string{"retired kid", r.GetRetiredKid()}, [2]string{"published until", shortTime(r.GetRetiredUntil(), "")})
68+ }
69+ return fields(out, pairs)
70+ }
71+ return cmd
72+}
M internal/app/e2e_helpers_test.go +1 -1
@@ -101,7 +101,7 @@ func newHarnessWith(t *testing.T, o harnessOpts, tweak func(*options)) *harness
101101 if hookExe == "" {
102102 hookExe = binary(t)
103103 }
104- opts := options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery}
104+ opts := options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery, oidcKey: oidcTestKeys(t)}
105105 if tweak != nil {
106106 tweak(&opts)
107107 }
@@ -101,7 +101,7 @@ func newHarnessWith(t *testing.T, o harnessOpts, tweak func(*options)) *harness
101 if hookExe == "" {101 if hookExe == "" {
102 hookExe = binary(t)102 hookExe = binary(t)
103 }103 }
104- opts := options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery}104+ opts := options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery, oidcKey: oidcTestKeys(t)}
105 if tweak != nil {105 if tweak != nil {
106 tweak(&opts)106 tweak(&opts)
107 }107 }
M internal/app/forge.go +30 -3
@@ -6,6 +6,7 @@ import (
66 "context"
77 "crypto/ed25519"
88 "crypto/rand"
9+ "crypto/rsa"
910 "errors"
1011 "fmt"
1112 "net"
@@ -26,6 +27,7 @@ import (
2627 "bnema.dev/ermite/internal/adapters/gitremote"
2728 "bnema.dev/ermite/internal/adapters/httpserver"
2829 "bnema.dev/ermite/internal/adapters/kvstore"
30+ "bnema.dev/ermite/internal/adapters/oidchttp"
2931 "bnema.dev/ermite/internal/adapters/rpc"
3032 "bnema.dev/ermite/internal/adapters/secretbox"
3133 "bnema.dev/ermite/internal/adapters/sshsig"
@@ -67,6 +69,9 @@ type options struct {
6769 // ci overrides the settings of the CI engine (tests shorten its
6870 // durations).
6971 ci func(*usecase.CIConfig)
72+ // oidcKey generates the signing keys of the ID tokens; nil generates RSA
73+ // keys (tests give prepared ones: a 3072-bit key takes a while).
74+ oidcKey func() (*rsa.PrivateKey, error)
7075 }
7176
7277 // remote returns the lister of github.com refs: the test's, else def.
@@ -117,8 +122,11 @@ type forge struct {
117122 CI *usecase.CI
118123 // Settings are the secrets, variables and environments of CI.
119124 Settings *usecase.CISettings
120- API *rpc.API
121- Bus ports.EventBus
125+ // OIDC is the provider of the ID tokens of CI jobs; nil without a secrets
126+ // key.
127+ OIDC *usecase.OIDC
128+ API *rpc.API
129+ Bus ports.EventBus
122130 }
123131
124132 // newSigningSeed makes the seed of a new ed25519 signing key.
@@ -242,6 +250,14 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
242250 if err != nil {
243251 return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err)
244252 }
253+ // The OIDC provider of CI jobs (CI-4): its signing key is sealed with the
254+ // secrets key, so there is none without one.
255+ f.OIDC, err = usecase.NewOIDC(ctx, usecase.OIDCConfig{
256+ Store: store, Sealer: sealer, Tokens: store.CI(), Clock: clock, PublicURL: cfg.PublicURL, NewKey: opts.oidcKey,
257+ })
258+ if err != nil {
259+ return nil, fmt.Errorf("oidc: %w", err)
260+ }
245261
246262 // CI (ADR 0025): the engine reads workflows through the reader, and plans
247263 // them in the background; a push only queues its refs.
@@ -259,6 +275,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
259275 RefTips: store.CI(),
260276 Settings: f.Settings,
261277 Workflows: opts.workflows(githubraw.New(githubraw.Config{})),
278+ OIDC: f.OIDC,
262279 }
263280 f.pullRequests(&ciCfg, store, git, committer)
264281 if opts.ci != nil {
@@ -317,6 +334,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
317334 if f.Passkeys != nil {
318335 apiPasskeys = f.Passkeys
319336 }
337+ var apiOIDC ports.OIDCKeyService // same rule as apiPasskeys
338+ if f.OIDC != nil {
339+ apiOIDC = f.OIDC
340+ }
320341 f.API = rpc.New(rpc.Config{
321342 Auth: f.Auth,
322343 Repos: f.Repos,
@@ -332,6 +353,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
332353 Secrets: f.Settings,
333354 Variables: f.Settings,
334355 Environments: f.Settings,
356+ OIDCKeys: apiOIDC,
335357 Version: serverVersion(),
336358 TrustedProxies: cfg.TrustedProxies,
337359 Limiter: limiter,
@@ -348,7 +370,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
348370 Limiter: limiter,
349371 MaxBodyBytes: maxBody,
350372 })
351- routes := make([]httpserver.Route, 0, 2*len(githttp.Patterns)+len(githubapi.Patterns)+len(web.Patterns))
373+ routes := make([]httpserver.Route, 0, 2*len(githttp.Patterns)+len(githubapi.Patterns)+len(oidchttp.Patterns)+len(web.Patterns))
352374 for _, p := range githttp.Patterns {
353375 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
354376 }
@@ -363,6 +385,11 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
363385 for _, p := range githubapi.Patterns {
364386 routes = append(routes, httpserver.Route{Pattern: p, Handler: api})
365387 }
388+ // The OIDC provider of CI jobs (issuer <public URL>/-/oidc).
389+ idp := oidchttp.New(oidchttp.Config{OIDC: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter})
390+ for _, p := range oidchttp.Patterns {
391+ routes = append(routes, httpserver.Route{Pattern: p, Handler: idp})
392+ }
366393 warnSessionsNeedHTTPS(ctx, cfg.PublicURL)
367394 warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient)
368395 site, err := newWeb(cfg, f, limiter)
@@ -6,6 +6,7 @@ import (
6 "context"6 "context"
7 "crypto/ed25519"7 "crypto/ed25519"
8 "crypto/rand"8 "crypto/rand"
9+ "crypto/rsa"
9 "errors"10 "errors"
10 "fmt"11 "fmt"
11 "net"12 "net"
@@ -26,6 +27,7 @@ import (
26 "bnema.dev/ermite/internal/adapters/gitremote"27 "bnema.dev/ermite/internal/adapters/gitremote"
27 "bnema.dev/ermite/internal/adapters/httpserver"28 "bnema.dev/ermite/internal/adapters/httpserver"
28 "bnema.dev/ermite/internal/adapters/kvstore"29 "bnema.dev/ermite/internal/adapters/kvstore"
30+ "bnema.dev/ermite/internal/adapters/oidchttp"
29 "bnema.dev/ermite/internal/adapters/rpc"31 "bnema.dev/ermite/internal/adapters/rpc"
30 "bnema.dev/ermite/internal/adapters/secretbox"32 "bnema.dev/ermite/internal/adapters/secretbox"
31 "bnema.dev/ermite/internal/adapters/sshsig"33 "bnema.dev/ermite/internal/adapters/sshsig"
@@ -67,6 +69,9 @@ type options struct {
67 // ci overrides the settings of the CI engine (tests shorten its69 // ci overrides the settings of the CI engine (tests shorten its
68 // durations).70 // durations).
69 ci func(*usecase.CIConfig)71 ci func(*usecase.CIConfig)
72+ // oidcKey generates the signing keys of the ID tokens; nil generates RSA
73+ // keys (tests give prepared ones: a 3072-bit key takes a while).
74+ oidcKey func() (*rsa.PrivateKey, error)
70 }75 }
71 76
72 // remote returns the lister of github.com refs: the test's, else def.77 // remote returns the lister of github.com refs: the test's, else def.
@@ -117,8 +122,11 @@ type forge struct {
117 CI *usecase.CI122 CI *usecase.CI
118 // Settings are the secrets, variables and environments of CI.123 // Settings are the secrets, variables and environments of CI.
119 Settings *usecase.CISettings124 Settings *usecase.CISettings
120- API *rpc.API125+ // OIDC is the provider of the ID tokens of CI jobs; nil without a secrets
121- Bus ports.EventBus126+ // key.
127+ OIDC *usecase.OIDC
128+ API *rpc.API
129+ Bus ports.EventBus
122 }130 }
123 131
124 // newSigningSeed makes the seed of a new ed25519 signing key.132 // newSigningSeed makes the seed of a new ed25519 signing key.
@@ -242,6 +250,14 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
242 if err != nil {250 if err != nil {
243 return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err)251 return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err)
244 }252 }
253+ // The OIDC provider of CI jobs (CI-4): its signing key is sealed with the
254+ // secrets key, so there is none without one.
255+ f.OIDC, err = usecase.NewOIDC(ctx, usecase.OIDCConfig{
256+ Store: store, Sealer: sealer, Tokens: store.CI(), Clock: clock, PublicURL: cfg.PublicURL, NewKey: opts.oidcKey,
257+ })
258+ if err != nil {
259+ return nil, fmt.Errorf("oidc: %w", err)
260+ }
245 261
246 // CI (ADR 0025): the engine reads workflows through the reader, and plans262 // CI (ADR 0025): the engine reads workflows through the reader, and plans
247 // them in the background; a push only queues its refs.263 // them in the background; a push only queues its refs.
@@ -259,6 +275,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
259 RefTips: store.CI(),275 RefTips: store.CI(),
260 Settings: f.Settings,276 Settings: f.Settings,
261 Workflows: opts.workflows(githubraw.New(githubraw.Config{})),277 Workflows: opts.workflows(githubraw.New(githubraw.Config{})),
278+ OIDC: f.OIDC,
262 }279 }
263 f.pullRequests(&ciCfg, store, git, committer)280 f.pullRequests(&ciCfg, store, git, committer)
264 if opts.ci != nil {281 if opts.ci != nil {
@@ -317,6 +334,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
317 if f.Passkeys != nil {334 if f.Passkeys != nil {
318 apiPasskeys = f.Passkeys335 apiPasskeys = f.Passkeys
319 }336 }
337+ var apiOIDC ports.OIDCKeyService // same rule as apiPasskeys
338+ if f.OIDC != nil {
339+ apiOIDC = f.OIDC
340+ }
320 f.API = rpc.New(rpc.Config{341 f.API = rpc.New(rpc.Config{
321 Auth: f.Auth,342 Auth: f.Auth,
322 Repos: f.Repos,343 Repos: f.Repos,
@@ -332,6 +353,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
332 Secrets: f.Settings,353 Secrets: f.Settings,
333 Variables: f.Settings,354 Variables: f.Settings,
334 Environments: f.Settings,355 Environments: f.Settings,
356+ OIDCKeys: apiOIDC,
335 Version: serverVersion(),357 Version: serverVersion(),
336 TrustedProxies: cfg.TrustedProxies,358 TrustedProxies: cfg.TrustedProxies,
337 Limiter: limiter,359 Limiter: limiter,
@@ -348,7 +370,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
348 Limiter: limiter,370 Limiter: limiter,
349 MaxBodyBytes: maxBody,371 MaxBodyBytes: maxBody,
350 })372 })
351- routes := make([]httpserver.Route, 0, 2*len(githttp.Patterns)+len(githubapi.Patterns)+len(web.Patterns))373+ routes := make([]httpserver.Route, 0, 2*len(githttp.Patterns)+len(githubapi.Patterns)+len(oidchttp.Patterns)+len(web.Patterns))
352 for _, p := range githttp.Patterns {374 for _, p := range githttp.Patterns {
353 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})375 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
354 }376 }
@@ -363,6 +385,11 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
363 for _, p := range githubapi.Patterns {385 for _, p := range githubapi.Patterns {
364 routes = append(routes, httpserver.Route{Pattern: p, Handler: api})386 routes = append(routes, httpserver.Route{Pattern: p, Handler: api})
365 }387 }
388+ // The OIDC provider of CI jobs (issuer <public URL>/-/oidc).
389+ idp := oidchttp.New(oidchttp.Config{OIDC: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter})
390+ for _, p := range oidchttp.Patterns {
391+ routes = append(routes, httpserver.Route{Pattern: p, Handler: idp})
392+ }
366 warnSessionsNeedHTTPS(ctx, cfg.PublicURL)393 warnSessionsNeedHTTPS(ctx, cfg.PublicURL)
367 warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient)394 warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient)
368 site, err := newWeb(cfg, f, limiter)395 site, err := newWeb(cfg, f, limiter)
M internal/app/root.go +1 -1
@@ -32,7 +32,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
3232 newServeCmd(), newHookCmd(),
3333 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
3434 newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
35- newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env),
35+ newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env),
3636 )
3737 return root
3838 }
@@ -32,7 +32,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
32 newServeCmd(), newHookCmd(),32 newServeCmd(), newHookCmd(),
33 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),33 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
34 newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),34 newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
35- newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env),35+ newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env),
36 )36 )
37 return root37 return root
38 }38 }
A internal/domain/ci_oidc.go +88 -0
@@ -0,0 +1,88 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "time"
6+
7+// OIDC ID tokens of CI jobs (CI-4, D5): a job asks the server for an RS256 JWT
8+// with GitHub's claims, which a relying party (Vault, a cloud) verifies against
9+// the server's JWKS instead of holding a long-lived secret.
10+
11+// OIDCRequestTokenPrefix starts the secret a job presents to the token
12+// endpoint (ACTIONS_ID_TOKEN_REQUEST_TOKEN). It is a job repository token
13+// (CIRepoToken.OIDC) that authenticates nothing else.
14+const OIDCRequestTokenPrefix = "ermiteoidc_"
15+
16+// IsOIDCRequestToken reports whether s has the shape of an ID token request
17+// token.
18+func IsOIDCRequestToken(s string) bool { return hasTokenShape(s, OIDCRequestTokenPrefix) }
19+
20+// PermIDToken is the permission that lets a job request ID tokens.
21+const PermIDToken = "id-token"
22+
23+// IDTokenAllowed reports whether a job with the permissions stored for it may
24+// request ID tokens: the run is trusted and the job has id-token: write. The
25+// permissions are lowered by the trust rule first (TokenPermissions), so an
26+// untrusted run never qualifies, whatever it asks.
27+func IDTokenAllowed(stored map[string]string, trusted bool) bool {
28+ return trusted && TokenPermissions(stored, trusted)[PermIDToken] == PermissionWrite
29+}
30+
31+// OIDCKeyRetention is how long a replaced signing key stays in the JWKS: far
32+// longer than the life of the tokens it signed (OIDCTokenLifetime).
33+const OIDCKeyRetention = 24 * time.Hour
34+
35+// OIDCTokenLifetime is how long an ID token is valid after it is issued, and
36+// OIDCNotBeforeSkew how far before its issue it is already valid (GitHub's
37+// values: exp = iat + 5 min, nbf = iat - 10 min).
38+const (
39+ OIDCTokenLifetime = 5 * time.Minute
40+ OIDCNotBeforeSkew = 10 * time.Minute
41+)
42+
43+// OIDCRunnerEnvironment is the runner_environment claim: ermite's runners are
44+// the owner's machines, which GitHub calls self-hosted.
45+const OIDCRunnerEnvironment = "self-hosted"
46+
47+// MaxOIDCAudienceLen bounds the audience a job may ask for.
48+const MaxOIDCAudienceLen = 1024
49+
50+// OIDCKey is a signing key of the ID tokens as stored: the private key sealed
51+// with the secrets key, bound to its path (OIDCKeyPath). A key without
52+// RetiredAt signs; a retired one is only published until RetiredAt plus
53+// OIDCKeyRetention.
54+type OIDCKey struct {
55+ // ID is the kid: the RFC 7638 thumbprint of the public key.
56+ ID string
57+ // Sealed is the PKCS #8 DER of the private key, sealed.
58+ Sealed []byte
59+ CreatedAt time.Time
60+ RetiredAt time.Time
61+}
62+
63+// OIDCKeyPath is where the key with that id is stored, and the additional data
64+// it is sealed with, so that a sealed key moved to another id does not open.
65+func OIDCKeyPath(kid string) string { return "oidc/key/" + kid }
66+
67+// Published reports whether the key is still in the JWKS at now.
68+func (k OIDCKey) Published(now time.Time) bool {
69+ return k.RetiredAt.IsZero() || now.Before(k.RetiredAt.Add(OIDCKeyRetention))
70+}
71+
72+// OIDCSubject is the sub claim of a job's ID token in GitHub's default format:
73+// the environment of the job when it has one, else "pull_request" for the
74+// events of merge requests, else the ref. repository is "owner/name".
75+//
76+// GitHub gives pull_request_target runs the pull_request context too, though
77+// they run the workflow of the default branch: a relying party that trusts
78+// "repo:o/r:pull_request" trusts both.
79+func OIDCSubject(repository, eventName, environment, ref string) string {
80+ prefix := "repo:" + repository + ":"
81+ switch {
82+ case environment != "":
83+ return prefix + "environment:" + environment
84+ case eventName == "pull_request" || eventName == "pull_request_target":
85+ return prefix + "pull_request"
86+ }
87+ return prefix + "ref:" + ref
88+}
@@ -0,0 +1,88 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "time"
6+
7+// OIDC ID tokens of CI jobs (CI-4, D5): a job asks the server for an RS256 JWT
8+// with GitHub's claims, which a relying party (Vault, a cloud) verifies against
9+// the server's JWKS instead of holding a long-lived secret.
10+
11+// OIDCRequestTokenPrefix starts the secret a job presents to the token
12+// endpoint (ACTIONS_ID_TOKEN_REQUEST_TOKEN). It is a job repository token
13+// (CIRepoToken.OIDC) that authenticates nothing else.
14+const OIDCRequestTokenPrefix = "ermiteoidc_"
15+
16+// IsOIDCRequestToken reports whether s has the shape of an ID token request
17+// token.
18+func IsOIDCRequestToken(s string) bool { return hasTokenShape(s, OIDCRequestTokenPrefix) }
19+
20+// PermIDToken is the permission that lets a job request ID tokens.
21+const PermIDToken = "id-token"
22+
23+// IDTokenAllowed reports whether a job with the permissions stored for it may
24+// request ID tokens: the run is trusted and the job has id-token: write. The
25+// permissions are lowered by the trust rule first (TokenPermissions), so an
26+// untrusted run never qualifies, whatever it asks.
27+func IDTokenAllowed(stored map[string]string, trusted bool) bool {
28+ return trusted && TokenPermissions(stored, trusted)[PermIDToken] == PermissionWrite
29+}
30+
31+// OIDCKeyRetention is how long a replaced signing key stays in the JWKS: far
32+// longer than the life of the tokens it signed (OIDCTokenLifetime).
33+const OIDCKeyRetention = 24 * time.Hour
34+
35+// OIDCTokenLifetime is how long an ID token is valid after it is issued, and
36+// OIDCNotBeforeSkew how far before its issue it is already valid (GitHub's
37+// values: exp = iat + 5 min, nbf = iat - 10 min).
38+const (
39+ OIDCTokenLifetime = 5 * time.Minute
40+ OIDCNotBeforeSkew = 10 * time.Minute
41+)
42+
43+// OIDCRunnerEnvironment is the runner_environment claim: ermite's runners are
44+// the owner's machines, which GitHub calls self-hosted.
45+const OIDCRunnerEnvironment = "self-hosted"
46+
47+// MaxOIDCAudienceLen bounds the audience a job may ask for.
48+const MaxOIDCAudienceLen = 1024
49+
50+// OIDCKey is a signing key of the ID tokens as stored: the private key sealed
51+// with the secrets key, bound to its path (OIDCKeyPath). A key without
52+// RetiredAt signs; a retired one is only published until RetiredAt plus
53+// OIDCKeyRetention.
54+type OIDCKey struct {
55+ // ID is the kid: the RFC 7638 thumbprint of the public key.
56+ ID string
57+ // Sealed is the PKCS #8 DER of the private key, sealed.
58+ Sealed []byte
59+ CreatedAt time.Time
60+ RetiredAt time.Time
61+}
62+
63+// OIDCKeyPath is where the key with that id is stored, and the additional data
64+// it is sealed with, so that a sealed key moved to another id does not open.
65+func OIDCKeyPath(kid string) string { return "oidc/key/" + kid }
66+
67+// Published reports whether the key is still in the JWKS at now.
68+func (k OIDCKey) Published(now time.Time) bool {
69+ return k.RetiredAt.IsZero() || now.Before(k.RetiredAt.Add(OIDCKeyRetention))
70+}
71+
72+// OIDCSubject is the sub claim of a job's ID token in GitHub's default format:
73+// the environment of the job when it has one, else "pull_request" for the
74+// events of merge requests, else the ref. repository is "owner/name".
75+//
76+// GitHub gives pull_request_target runs the pull_request context too, though
77+// they run the workflow of the default branch: a relying party that trusts
78+// "repo:o/r:pull_request" trusts both.
79+func OIDCSubject(repository, eventName, environment, ref string) string {
80+ prefix := "repo:" + repository + ":"
81+ switch {
82+ case environment != "":
83+ return prefix + "environment:" + environment
84+ case eventName == "pull_request" || eventName == "pull_request_target":
85+ return prefix + "pull_request"
86+ }
87+ return prefix + "ref:" + ref
88+}
A internal/domain/ci_oidc_test.go +74 -0
@@ -0,0 +1,74 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestIsOIDCRequestToken(t *testing.T) {
16+ t.Parallel()
17+ ok := domain.OIDCRequestTokenPrefix + strings.Repeat("aB3-_", 8) + "xyz"
18+ assert.True(t, domain.IsOIDCRequestToken(ok))
19+ assert.False(t, domain.IsRepoToken(ok), "not a repository token")
20+ for _, bad := range []string{"", domain.OIDCRequestTokenPrefix, ok + "x", strings.Replace(ok, "ermiteoidc_", "ermitegh_", 1)} {
21+ assert.False(t, domain.IsOIDCRequestToken(bad), bad)
22+ }
23+}
24+
25+// Only a trusted run with id-token: write gets ID tokens: GitHub's rule, plus
26+// ermite's trust rule (an untrusted run's writes are lowered, and id-token has
27+// no read).
28+func TestIDTokenAllowed(t *testing.T) {
29+ t.Parallel()
30+ write := map[string]string{"id-token": "write", "contents": "read"}
31+ assert.True(t, domain.IDTokenAllowed(write, true))
32+ assert.False(t, domain.IDTokenAllowed(write, false), "untrusted")
33+ assert.False(t, domain.IDTokenAllowed(map[string]string{"id-token": "read"}, true), "read is not a level of id-token")
34+ assert.False(t, domain.IDTokenAllowed(nil, true), "the default has no id-token")
35+ assert.False(t, domain.IDTokenAllowed(map[string]string{"contents": "write"}, true))
36+ all := domain.ResolvePermissions(&domain.PermissionSpec{All: domain.PermissionsWriteAll}, nil)
37+ assert.True(t, domain.IDTokenAllowed(all, true), "write-all")
38+ readAll := domain.ResolvePermissions(&domain.PermissionSpec{All: domain.PermissionsReadAll}, nil)
39+ assert.False(t, domain.IDTokenAllowed(readAll, true), "read-all")
40+}
41+
42+func TestOIDCSubject(t *testing.T) {
43+ t.Parallel()
44+ tests := []struct{ event, env, ref, want string }{
45+ {"push", "", "refs/heads/main", "repo:o/r:ref:refs/heads/main"},
46+ {"push", "", "refs/tags/v1", "repo:o/r:ref:refs/tags/v1"},
47+ {"workflow_dispatch", "", "refs/heads/dev", "repo:o/r:ref:refs/heads/dev"},
48+ {"pull_request", "", "refs/pull/1/merge", "repo:o/r:pull_request"},
49+ {"pull_request_target", "", "refs/heads/main", "repo:o/r:pull_request"},
50+ {"pull_request", "prod", "refs/pull/1/merge", "repo:o/r:environment:prod"},
51+ {"push", "Prod", "refs/heads/main", "repo:o/r:environment:Prod"},
52+ }
53+ for _, tt := range tests {
54+ assert.Equal(t, tt.want, domain.OIDCSubject("o/r", tt.event, tt.env, tt.ref), tt)
55+ }
56+}
57+
58+func TestOIDCKeyPublished(t *testing.T) {
59+ t.Parallel()
60+ at := time.Date(2026, 10, 16, 0, 0, 0, 0, time.UTC)
61+ assert.True(t, domain.OIDCKey{}.Published(at), "an active key")
62+ k := domain.OIDCKey{RetiredAt: at}
63+ assert.True(t, k.Published(at.Add(24*time.Hour-time.Second)))
64+ assert.False(t, k.Published(at.Add(24*time.Hour)))
65+ assert.Equal(t, "oidc/key/abc", domain.OIDCKeyPath("abc"))
66+}
67+
68+// An ID token request token is nobody outside the token endpoint.
69+func TestOIDCRequestTokenHasNoRights(t *testing.T) {
70+ t.Parallel()
71+ p := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "x", OIDC: true, Permissions: map[string]string{"contents": "write"}})
72+ assert.True(t, p.IsAnonymous())
73+ assert.Empty(t, p.Scopes())
74+}
@@ -0,0 +1,74 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestIsOIDCRequestToken(t *testing.T) {
16+ t.Parallel()
17+ ok := domain.OIDCRequestTokenPrefix + strings.Repeat("aB3-_", 8) + "xyz"
18+ assert.True(t, domain.IsOIDCRequestToken(ok))
19+ assert.False(t, domain.IsRepoToken(ok), "not a repository token")
20+ for _, bad := range []string{"", domain.OIDCRequestTokenPrefix, ok + "x", strings.Replace(ok, "ermiteoidc_", "ermitegh_", 1)} {
21+ assert.False(t, domain.IsOIDCRequestToken(bad), bad)
22+ }
23+}
24+
25+// Only a trusted run with id-token: write gets ID tokens: GitHub's rule, plus
26+// ermite's trust rule (an untrusted run's writes are lowered, and id-token has
27+// no read).
28+func TestIDTokenAllowed(t *testing.T) {
29+ t.Parallel()
30+ write := map[string]string{"id-token": "write", "contents": "read"}
31+ assert.True(t, domain.IDTokenAllowed(write, true))
32+ assert.False(t, domain.IDTokenAllowed(write, false), "untrusted")
33+ assert.False(t, domain.IDTokenAllowed(map[string]string{"id-token": "read"}, true), "read is not a level of id-token")
34+ assert.False(t, domain.IDTokenAllowed(nil, true), "the default has no id-token")
35+ assert.False(t, domain.IDTokenAllowed(map[string]string{"contents": "write"}, true))
36+ all := domain.ResolvePermissions(&domain.PermissionSpec{All: domain.PermissionsWriteAll}, nil)
37+ assert.True(t, domain.IDTokenAllowed(all, true), "write-all")
38+ readAll := domain.ResolvePermissions(&domain.PermissionSpec{All: domain.PermissionsReadAll}, nil)
39+ assert.False(t, domain.IDTokenAllowed(readAll, true), "read-all")
40+}
41+
42+func TestOIDCSubject(t *testing.T) {
43+ t.Parallel()
44+ tests := []struct{ event, env, ref, want string }{
45+ {"push", "", "refs/heads/main", "repo:o/r:ref:refs/heads/main"},
46+ {"push", "", "refs/tags/v1", "repo:o/r:ref:refs/tags/v1"},
47+ {"workflow_dispatch", "", "refs/heads/dev", "repo:o/r:ref:refs/heads/dev"},
48+ {"pull_request", "", "refs/pull/1/merge", "repo:o/r:pull_request"},
49+ {"pull_request_target", "", "refs/heads/main", "repo:o/r:pull_request"},
50+ {"pull_request", "prod", "refs/pull/1/merge", "repo:o/r:environment:prod"},
51+ {"push", "Prod", "refs/heads/main", "repo:o/r:environment:Prod"},
52+ }
53+ for _, tt := range tests {
54+ assert.Equal(t, tt.want, domain.OIDCSubject("o/r", tt.event, tt.env, tt.ref), tt)
55+ }
56+}
57+
58+func TestOIDCKeyPublished(t *testing.T) {
59+ t.Parallel()
60+ at := time.Date(2026, 10, 16, 0, 0, 0, 0, time.UTC)
61+ assert.True(t, domain.OIDCKey{}.Published(at), "an active key")
62+ k := domain.OIDCKey{RetiredAt: at}
63+ assert.True(t, k.Published(at.Add(24*time.Hour-time.Second)))
64+ assert.False(t, k.Published(at.Add(24*time.Hour)))
65+ assert.Equal(t, "oidc/key/abc", domain.OIDCKeyPath("abc"))
66+}
67+
68+// An ID token request token is nobody outside the token endpoint.
69+func TestOIDCRequestTokenHasNoRights(t *testing.T) {
70+ t.Parallel()
71+ p := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "x", OIDC: true, Permissions: map[string]string{"contents": "write"}})
72+ assert.True(t, p.IsAnonymous())
73+ assert.Empty(t, p.Scopes())
74+}
M internal/domain/ci_token.go +10 -0
@@ -43,6 +43,10 @@ type CIRepoToken struct {
4343 Archive bool
4444 // Sha is the commit an archive token is bound to; empty for the job's own.
4545 Sha string
46+ // OIDC is set for the ID token request token of the job
47+ // (OIDCRequestTokenPrefix): it only asks the token endpoint for the job's
48+ // ID tokens, and authenticates nothing else (RepoTokenPrincipal).
49+ OIDC bool
4650 // Permissions are the permissions of the job's own token (key to level, see
4751 // TokenPermissions). Empty: the default, which reads the repository.
4852 Permissions map[string]string
@@ -74,7 +78,13 @@ const RepoTokenGrace = 5 * time.Minute
7478 // does not show itself to it.
7579 //
7680 // Both have a TokenID that starts with "job:" and matches no stored token.
81+//
82+// An ID token request token is nobody: it is anonymous everywhere but at the
83+// token endpoint, which looks it up itself.
7784 func RepoTokenPrincipal(t CIRepoToken) Principal {
85+ if t.OIDC {
86+ return Anonymous()
87+ }
7888 if t.Archive {
7989 return Principal{
8090 kind: kindArchive, tokenID: repoTokenIDPrefix + t.ID, label: "ci archive", expiresAt: t.ExpiresAt,
@@ -43,6 +43,10 @@ type CIRepoToken struct {
43 Archive bool43 Archive bool
44 // Sha is the commit an archive token is bound to; empty for the job's own.44 // Sha is the commit an archive token is bound to; empty for the job's own.
45 Sha string45 Sha string
46+ // OIDC is set for the ID token request token of the job
47+ // (OIDCRequestTokenPrefix): it only asks the token endpoint for the job's
48+ // ID tokens, and authenticates nothing else (RepoTokenPrincipal).
49+ OIDC bool
46 // Permissions are the permissions of the job's own token (key to level, see50 // Permissions are the permissions of the job's own token (key to level, see
47 // TokenPermissions). Empty: the default, which reads the repository.51 // TokenPermissions). Empty: the default, which reads the repository.
48 Permissions map[string]string52 Permissions map[string]string
@@ -74,7 +78,13 @@ const RepoTokenGrace = 5 * time.Minute
74 // does not show itself to it.78 // does not show itself to it.
75 //79 //
76 // Both have a TokenID that starts with "job:" and matches no stored token.80 // Both have a TokenID that starts with "job:" and matches no stored token.
81+//
82+// An ID token request token is nobody: it is anonymous everywhere but at the
83+// token endpoint, which looks it up itself.
77 func RepoTokenPrincipal(t CIRepoToken) Principal {84 func RepoTokenPrincipal(t CIRepoToken) Principal {
85+ if t.OIDC {
86+ return Anonymous()
87+ }
78 if t.Archive {88 if t.Archive {
79 return Principal{89 return Principal{
80 kind: kindArchive, tokenID: repoTokenIDPrefix + t.ID, label: "ci archive", expiresAt: t.ExpiresAt,90 kind: kindArchive, tokenID: repoTokenIDPrefix + t.ID, label: "ci archive", expiresAt: t.ExpiresAt,
M internal/ports/ci.go +5 -0
@@ -433,6 +433,11 @@ type RunnerJob struct {
433433 Permissions map[string]string
434434 // Cache is the scope of the job in the runner's cache (Job.cache).
435435 Cache JobCache
436+ // IDTokenURL is the token endpoint of the job's OIDC ID tokens and
437+ // IDTokenRequestToken its bearer (Job.id_token_url and
438+ // id_token_request_token); both "" when the job may not have ID tokens.
439+ IDTokenURL string
440+ IDTokenRequestToken string
436441 }
437442
438443 // JobCache is what a job may do in the cache the runner serves: save entries
@@ -433,6 +433,11 @@ type RunnerJob struct {
433 Permissions map[string]string433 Permissions map[string]string
434 // Cache is the scope of the job in the runner's cache (Job.cache).434 // Cache is the scope of the job in the runner's cache (Job.cache).
435 Cache JobCache435 Cache JobCache
436+ // IDTokenURL is the token endpoint of the job's OIDC ID tokens and
437+ // IDTokenRequestToken its bearer (Job.id_token_url and
438+ // id_token_request_token); both "" when the job may not have ID tokens.
439+ IDTokenURL string
440+ IDTokenRequestToken string
436 }441 }
437 442
438 // JobCache is what a job may do in the cache the runner serves: save entries443 // JobCache is what a job may do in the cache the runner serves: save entries
A internal/ports/mocks/OIDCKeyService.go +115 -0
@@ -0,0 +1,115 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewOIDCKeyService creates a new instance of OIDCKeyService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewOIDCKeyService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *OIDCKeyService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &OIDCKeyService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// OIDCKeyService is an autogenerated mock type for the OIDCKeyService type
39+type OIDCKeyService struct {
40+ mock.Mock
41+}
42+
43+type OIDCKeyService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *OIDCKeyService) EXPECT() *OIDCKeyService_Expecter {
48+ return &OIDCKeyService_Expecter{mock: &_m.Mock}
49+}
50+
51+// RotateKey provides a mock function for the type OIDCKeyService
52+func (_mock *OIDCKeyService) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) {
53+ ret := _mock.Called(ctx, p)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for RotateKey")
57+ }
58+
59+ var r0 ports.OIDCRotation
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) (ports.OIDCRotation, error)); ok {
62+ return returnFunc(ctx, p)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) ports.OIDCRotation); ok {
65+ r0 = returnFunc(ctx, p)
66+ } else {
67+ r0 = ret.Get(0).(ports.OIDCRotation)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok {
70+ r1 = returnFunc(ctx, p)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// OIDCKeyService_RotateKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RotateKey'
78+type OIDCKeyService_RotateKey_Call struct {
79+ *mock.Call
80+}
81+
82+// RotateKey is a helper method to define mock.On call
83+// - ctx context.Context
84+// - p domain.Principal
85+func (_e *OIDCKeyService_Expecter) RotateKey(ctx any, p any) *OIDCKeyService_RotateKey_Call {
86+ return &OIDCKeyService_RotateKey_Call{Call: _e.mock.On("RotateKey", ctx, p)}
87+}
88+
89+func (_c *OIDCKeyService_RotateKey_Call) Run(run func(ctx context.Context, p domain.Principal)) *OIDCKeyService_RotateKey_Call {
90+ _c.Call.Run(func(args mock.Arguments) {
91+ var arg0 context.Context
92+ if args[0] != nil {
93+ arg0 = args[0].(context.Context)
94+ }
95+ var arg1 domain.Principal
96+ if args[1] != nil {
97+ arg1 = args[1].(domain.Principal)
98+ }
99+ run(
100+ arg0,
101+ arg1,
102+ )
103+ })
104+ return _c
105+}
106+
107+func (_c *OIDCKeyService_RotateKey_Call) Return(oIDCRotation ports.OIDCRotation, err error) *OIDCKeyService_RotateKey_Call {
108+ _c.Call.Return(oIDCRotation, err)
109+ return _c
110+}
111+
112+func (_c *OIDCKeyService_RotateKey_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error)) *OIDCKeyService_RotateKey_Call {
113+ _c.Call.Return(run)
114+ return _c
115+}
@@ -0,0 +1,115 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewOIDCKeyService creates a new instance of OIDCKeyService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewOIDCKeyService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *OIDCKeyService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &OIDCKeyService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// OIDCKeyService is an autogenerated mock type for the OIDCKeyService type
39+type OIDCKeyService struct {
40+ mock.Mock
41+}
42+
43+type OIDCKeyService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *OIDCKeyService) EXPECT() *OIDCKeyService_Expecter {
48+ return &OIDCKeyService_Expecter{mock: &_m.Mock}
49+}
50+
51+// RotateKey provides a mock function for the type OIDCKeyService
52+func (_mock *OIDCKeyService) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) {
53+ ret := _mock.Called(ctx, p)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for RotateKey")
57+ }
58+
59+ var r0 ports.OIDCRotation
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) (ports.OIDCRotation, error)); ok {
62+ return returnFunc(ctx, p)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) ports.OIDCRotation); ok {
65+ r0 = returnFunc(ctx, p)
66+ } else {
67+ r0 = ret.Get(0).(ports.OIDCRotation)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok {
70+ r1 = returnFunc(ctx, p)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// OIDCKeyService_RotateKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RotateKey'
78+type OIDCKeyService_RotateKey_Call struct {
79+ *mock.Call
80+}
81+
82+// RotateKey is a helper method to define mock.On call
83+// - ctx context.Context
84+// - p domain.Principal
85+func (_e *OIDCKeyService_Expecter) RotateKey(ctx any, p any) *OIDCKeyService_RotateKey_Call {
86+ return &OIDCKeyService_RotateKey_Call{Call: _e.mock.On("RotateKey", ctx, p)}
87+}
88+
89+func (_c *OIDCKeyService_RotateKey_Call) Run(run func(ctx context.Context, p domain.Principal)) *OIDCKeyService_RotateKey_Call {
90+ _c.Call.Run(func(args mock.Arguments) {
91+ var arg0 context.Context
92+ if args[0] != nil {
93+ arg0 = args[0].(context.Context)
94+ }
95+ var arg1 domain.Principal
96+ if args[1] != nil {
97+ arg1 = args[1].(domain.Principal)
98+ }
99+ run(
100+ arg0,
101+ arg1,
102+ )
103+ })
104+ return _c
105+}
106+
107+func (_c *OIDCKeyService_RotateKey_Call) Return(oIDCRotation ports.OIDCRotation, err error) *OIDCKeyService_RotateKey_Call {
108+ _c.Call.Return(oIDCRotation, err)
109+ return _c
110+}
111+
112+func (_c *OIDCKeyService_RotateKey_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error)) *OIDCKeyService_RotateKey_Call {
113+ _c.Call.Return(run)
114+ return _c
115+}
A internal/ports/mocks/OIDCKeyStore.go +173 -0
@@ -0,0 +1,173 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewOIDCKeyStore creates a new instance of OIDCKeyStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewOIDCKeyStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *OIDCKeyStore {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &OIDCKeyStore{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// OIDCKeyStore is an autogenerated mock type for the OIDCKeyStore type
38+type OIDCKeyStore struct {
39+ mock.Mock
40+}
41+
42+type OIDCKeyStore_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *OIDCKeyStore) EXPECT() *OIDCKeyStore_Expecter {
47+ return &OIDCKeyStore_Expecter{mock: &_m.Mock}
48+}
49+
50+// ListOIDCKeys provides a mock function for the type OIDCKeyStore
51+func (_mock *OIDCKeyStore) ListOIDCKeys(ctx context.Context) ([]domain.OIDCKey, error) {
52+ ret := _mock.Called(ctx)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for ListOIDCKeys")
56+ }
57+
58+ var r0 []domain.OIDCKey
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.OIDCKey, error)); ok {
61+ return returnFunc(ctx)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.OIDCKey); ok {
64+ r0 = returnFunc(ctx)
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).([]domain.OIDCKey)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
71+ r1 = returnFunc(ctx)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// OIDCKeyStore_ListOIDCKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOIDCKeys'
79+type OIDCKeyStore_ListOIDCKeys_Call struct {
80+ *mock.Call
81+}
82+
83+// ListOIDCKeys is a helper method to define mock.On call
84+// - ctx context.Context
85+func (_e *OIDCKeyStore_Expecter) ListOIDCKeys(ctx any) *OIDCKeyStore_ListOIDCKeys_Call {
86+ return &OIDCKeyStore_ListOIDCKeys_Call{Call: _e.mock.On("ListOIDCKeys", ctx)}
87+}
88+
89+func (_c *OIDCKeyStore_ListOIDCKeys_Call) Run(run func(ctx context.Context)) *OIDCKeyStore_ListOIDCKeys_Call {
90+ _c.Call.Run(func(args mock.Arguments) {
91+ var arg0 context.Context
92+ if args[0] != nil {
93+ arg0 = args[0].(context.Context)
94+ }
95+ run(
96+ arg0,
97+ )
98+ })
99+ return _c
100+}
101+
102+func (_c *OIDCKeyStore_ListOIDCKeys_Call) Return(oIDCKeys []domain.OIDCKey, err error) *OIDCKeyStore_ListOIDCKeys_Call {
103+ _c.Call.Return(oIDCKeys, err)
104+ return _c
105+}
106+
107+func (_c *OIDCKeyStore_ListOIDCKeys_Call) RunAndReturn(run func(ctx context.Context) ([]domain.OIDCKey, error)) *OIDCKeyStore_ListOIDCKeys_Call {
108+ _c.Call.Return(run)
109+ return _c
110+}
111+
112+// SaveOIDCKeys provides a mock function for the type OIDCKeyStore
113+func (_mock *OIDCKeyStore) SaveOIDCKeys(ctx context.Context, put []domain.OIDCKey, drop []string) error {
114+ ret := _mock.Called(ctx, put, drop)
115+
116+ if len(ret) == 0 {
117+ panic("no return value specified for SaveOIDCKeys")
118+ }
119+
120+ var r0 error
121+ if returnFunc, ok := ret.Get(0).(func(context.Context, []domain.OIDCKey, []string) error); ok {
122+ r0 = returnFunc(ctx, put, drop)
123+ } else {
124+ r0 = ret.Error(0)
125+ }
126+ return r0
127+}
128+
129+// OIDCKeyStore_SaveOIDCKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveOIDCKeys'
130+type OIDCKeyStore_SaveOIDCKeys_Call struct {
131+ *mock.Call
132+}
133+
134+// SaveOIDCKeys is a helper method to define mock.On call
135+// - ctx context.Context
136+// - put []domain.OIDCKey
137+// - drop []string
138+func (_e *OIDCKeyStore_Expecter) SaveOIDCKeys(ctx any, put any, drop any) *OIDCKeyStore_SaveOIDCKeys_Call {
139+ return &OIDCKeyStore_SaveOIDCKeys_Call{Call: _e.mock.On("SaveOIDCKeys", ctx, put, drop)}
140+}
141+
142+func (_c *OIDCKeyStore_SaveOIDCKeys_Call) Run(run func(ctx context.Context, put []domain.OIDCKey, drop []string)) *OIDCKeyStore_SaveOIDCKeys_Call {
143+ _c.Call.Run(func(args mock.Arguments) {
144+ var arg0 context.Context
145+ if args[0] != nil {
146+ arg0 = args[0].(context.Context)
147+ }
148+ var arg1 []domain.OIDCKey
149+ if args[1] != nil {
150+ arg1 = args[1].([]domain.OIDCKey)
151+ }
152+ var arg2 []string
153+ if args[2] != nil {
154+ arg2 = args[2].([]string)
155+ }
156+ run(
157+ arg0,
158+ arg1,
159+ arg2,
160+ )
161+ })
162+ return _c
163+}
164+
165+func (_c *OIDCKeyStore_SaveOIDCKeys_Call) Return(err error) *OIDCKeyStore_SaveOIDCKeys_Call {
166+ _c.Call.Return(err)
167+ return _c
168+}
169+
170+func (_c *OIDCKeyStore_SaveOIDCKeys_Call) RunAndReturn(run func(ctx context.Context, put []domain.OIDCKey, drop []string) error) *OIDCKeyStore_SaveOIDCKeys_Call {
171+ _c.Call.Return(run)
172+ return _c
173+}
@@ -0,0 +1,173 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewOIDCKeyStore creates a new instance of OIDCKeyStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewOIDCKeyStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *OIDCKeyStore {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &OIDCKeyStore{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// OIDCKeyStore is an autogenerated mock type for the OIDCKeyStore type
38+type OIDCKeyStore struct {
39+ mock.Mock
40+}
41+
42+type OIDCKeyStore_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *OIDCKeyStore) EXPECT() *OIDCKeyStore_Expecter {
47+ return &OIDCKeyStore_Expecter{mock: &_m.Mock}
48+}
49+
50+// ListOIDCKeys provides a mock function for the type OIDCKeyStore
51+func (_mock *OIDCKeyStore) ListOIDCKeys(ctx context.Context) ([]domain.OIDCKey, error) {
52+ ret := _mock.Called(ctx)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for ListOIDCKeys")
56+ }
57+
58+ var r0 []domain.OIDCKey
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.OIDCKey, error)); ok {
61+ return returnFunc(ctx)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.OIDCKey); ok {
64+ r0 = returnFunc(ctx)
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).([]domain.OIDCKey)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
71+ r1 = returnFunc(ctx)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// OIDCKeyStore_ListOIDCKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOIDCKeys'
79+type OIDCKeyStore_ListOIDCKeys_Call struct {
80+ *mock.Call
81+}
82+
83+// ListOIDCKeys is a helper method to define mock.On call
84+// - ctx context.Context
85+func (_e *OIDCKeyStore_Expecter) ListOIDCKeys(ctx any) *OIDCKeyStore_ListOIDCKeys_Call {
86+ return &OIDCKeyStore_ListOIDCKeys_Call{Call: _e.mock.On("ListOIDCKeys", ctx)}
87+}
88+
89+func (_c *OIDCKeyStore_ListOIDCKeys_Call) Run(run func(ctx context.Context)) *OIDCKeyStore_ListOIDCKeys_Call {
90+ _c.Call.Run(func(args mock.Arguments) {
91+ var arg0 context.Context
92+ if args[0] != nil {
93+ arg0 = args[0].(context.Context)
94+ }
95+ run(
96+ arg0,
97+ )
98+ })
99+ return _c
100+}
101+
102+func (_c *OIDCKeyStore_ListOIDCKeys_Call) Return(oIDCKeys []domain.OIDCKey, err error) *OIDCKeyStore_ListOIDCKeys_Call {
103+ _c.Call.Return(oIDCKeys, err)
104+ return _c
105+}
106+
107+func (_c *OIDCKeyStore_ListOIDCKeys_Call) RunAndReturn(run func(ctx context.Context) ([]domain.OIDCKey, error)) *OIDCKeyStore_ListOIDCKeys_Call {
108+ _c.Call.Return(run)
109+ return _c
110+}
111+
112+// SaveOIDCKeys provides a mock function for the type OIDCKeyStore
113+func (_mock *OIDCKeyStore) SaveOIDCKeys(ctx context.Context, put []domain.OIDCKey, drop []string) error {
114+ ret := _mock.Called(ctx, put, drop)
115+
116+ if len(ret) == 0 {
117+ panic("no return value specified for SaveOIDCKeys")
118+ }
119+
120+ var r0 error
121+ if returnFunc, ok := ret.Get(0).(func(context.Context, []domain.OIDCKey, []string) error); ok {
122+ r0 = returnFunc(ctx, put, drop)
123+ } else {
124+ r0 = ret.Error(0)
125+ }
126+ return r0
127+}
128+
129+// OIDCKeyStore_SaveOIDCKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveOIDCKeys'
130+type OIDCKeyStore_SaveOIDCKeys_Call struct {
131+ *mock.Call
132+}
133+
134+// SaveOIDCKeys is a helper method to define mock.On call
135+// - ctx context.Context
136+// - put []domain.OIDCKey
137+// - drop []string
138+func (_e *OIDCKeyStore_Expecter) SaveOIDCKeys(ctx any, put any, drop any) *OIDCKeyStore_SaveOIDCKeys_Call {
139+ return &OIDCKeyStore_SaveOIDCKeys_Call{Call: _e.mock.On("SaveOIDCKeys", ctx, put, drop)}
140+}
141+
142+func (_c *OIDCKeyStore_SaveOIDCKeys_Call) Run(run func(ctx context.Context, put []domain.OIDCKey, drop []string)) *OIDCKeyStore_SaveOIDCKeys_Call {
143+ _c.Call.Run(func(args mock.Arguments) {
144+ var arg0 context.Context
145+ if args[0] != nil {
146+ arg0 = args[0].(context.Context)
147+ }
148+ var arg1 []domain.OIDCKey
149+ if args[1] != nil {
150+ arg1 = args[1].([]domain.OIDCKey)
151+ }
152+ var arg2 []string
153+ if args[2] != nil {
154+ arg2 = args[2].([]string)
155+ }
156+ run(
157+ arg0,
158+ arg1,
159+ arg2,
160+ )
161+ })
162+ return _c
163+}
164+
165+func (_c *OIDCKeyStore_SaveOIDCKeys_Call) Return(err error) *OIDCKeyStore_SaveOIDCKeys_Call {
166+ _c.Call.Return(err)
167+ return _c
168+}
169+
170+func (_c *OIDCKeyStore_SaveOIDCKeys_Call) RunAndReturn(run func(ctx context.Context, put []domain.OIDCKey, drop []string) error) *OIDCKeyStore_SaveOIDCKeys_Call {
171+ _c.Call.Return(run)
172+ return _c
173+}
A internal/ports/mocks/OIDCService.go +273 -0
@@ -0,0 +1,273 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewOIDCService creates a new instance of OIDCService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewOIDCService(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *OIDCService {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &OIDCService{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// OIDCService is an autogenerated mock type for the OIDCService type
37+type OIDCService struct {
38+ mock.Mock
39+}
40+
41+type OIDCService_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *OIDCService) EXPECT() *OIDCService_Expecter {
46+ return &OIDCService_Expecter{mock: &_m.Mock}
47+}
48+
49+// IDToken provides a mock function for the type OIDCService
50+func (_mock *OIDCService) IDToken(ctx context.Context, secret string, audience string) (string, error) {
51+ ret := _mock.Called(ctx, secret, audience)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for IDToken")
55+ }
56+
57+ var r0 string
58+ var r1 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (string, error)); ok {
60+ return returnFunc(ctx, secret, audience)
61+ }
62+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) string); ok {
63+ r0 = returnFunc(ctx, secret, audience)
64+ } else {
65+ r0 = ret.Get(0).(string)
66+ }
67+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
68+ r1 = returnFunc(ctx, secret, audience)
69+ } else {
70+ r1 = ret.Error(1)
71+ }
72+ return r0, r1
73+}
74+
75+// OIDCService_IDToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IDToken'
76+type OIDCService_IDToken_Call struct {
77+ *mock.Call
78+}
79+
80+// IDToken is a helper method to define mock.On call
81+// - ctx context.Context
82+// - secret string
83+// - audience string
84+func (_e *OIDCService_Expecter) IDToken(ctx any, secret any, audience any) *OIDCService_IDToken_Call {
85+ return &OIDCService_IDToken_Call{Call: _e.mock.On("IDToken", ctx, secret, audience)}
86+}
87+
88+func (_c *OIDCService_IDToken_Call) Run(run func(ctx context.Context, secret string, audience string)) *OIDCService_IDToken_Call {
89+ _c.Call.Run(func(args mock.Arguments) {
90+ var arg0 context.Context
91+ if args[0] != nil {
92+ arg0 = args[0].(context.Context)
93+ }
94+ var arg1 string
95+ if args[1] != nil {
96+ arg1 = args[1].(string)
97+ }
98+ var arg2 string
99+ if args[2] != nil {
100+ arg2 = args[2].(string)
101+ }
102+ run(
103+ arg0,
104+ arg1,
105+ arg2,
106+ )
107+ })
108+ return _c
109+}
110+
111+func (_c *OIDCService_IDToken_Call) Return(s string, err error) *OIDCService_IDToken_Call {
112+ _c.Call.Return(s, err)
113+ return _c
114+}
115+
116+func (_c *OIDCService_IDToken_Call) RunAndReturn(run func(ctx context.Context, secret string, audience string) (string, error)) *OIDCService_IDToken_Call {
117+ _c.Call.Return(run)
118+ return _c
119+}
120+
121+// OIDCDiscovery provides a mock function for the type OIDCService
122+func (_mock *OIDCService) OIDCDiscovery() ([]byte, error) {
123+ ret := _mock.Called()
124+
125+ if len(ret) == 0 {
126+ panic("no return value specified for OIDCDiscovery")
127+ }
128+
129+ var r0 []byte
130+ var r1 error
131+ if returnFunc, ok := ret.Get(0).(func() ([]byte, error)); ok {
132+ return returnFunc()
133+ }
134+ if returnFunc, ok := ret.Get(0).(func() []byte); ok {
135+ r0 = returnFunc()
136+ } else {
137+ if ret.Get(0) != nil {
138+ r0 = ret.Get(0).([]byte)
139+ }
140+ }
141+ if returnFunc, ok := ret.Get(1).(func() error); ok {
142+ r1 = returnFunc()
143+ } else {
144+ r1 = ret.Error(1)
145+ }
146+ return r0, r1
147+}
148+
149+// OIDCService_OIDCDiscovery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OIDCDiscovery'
150+type OIDCService_OIDCDiscovery_Call struct {
151+ *mock.Call
152+}
153+
154+// OIDCDiscovery is a helper method to define mock.On call
155+func (_e *OIDCService_Expecter) OIDCDiscovery() *OIDCService_OIDCDiscovery_Call {
156+ return &OIDCService_OIDCDiscovery_Call{Call: _e.mock.On("OIDCDiscovery")}
157+}
158+
159+func (_c *OIDCService_OIDCDiscovery_Call) Run(run func()) *OIDCService_OIDCDiscovery_Call {
160+ _c.Call.Run(func(args mock.Arguments) {
161+ run()
162+ })
163+ return _c
164+}
165+
166+func (_c *OIDCService_OIDCDiscovery_Call) Return(bytes []byte, err error) *OIDCService_OIDCDiscovery_Call {
167+ _c.Call.Return(bytes, err)
168+ return _c
169+}
170+
171+func (_c *OIDCService_OIDCDiscovery_Call) RunAndReturn(run func() ([]byte, error)) *OIDCService_OIDCDiscovery_Call {
172+ _c.Call.Return(run)
173+ return _c
174+}
175+
176+// OIDCEnabled provides a mock function for the type OIDCService
177+func (_mock *OIDCService) OIDCEnabled() bool {
178+ ret := _mock.Called()
179+
180+ if len(ret) == 0 {
181+ panic("no return value specified for OIDCEnabled")
182+ }
183+
184+ var r0 bool
185+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
186+ r0 = returnFunc()
187+ } else {
188+ r0 = ret.Get(0).(bool)
189+ }
190+ return r0
191+}
192+
193+// OIDCService_OIDCEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OIDCEnabled'
194+type OIDCService_OIDCEnabled_Call struct {
195+ *mock.Call
196+}
197+
198+// OIDCEnabled is a helper method to define mock.On call
199+func (_e *OIDCService_Expecter) OIDCEnabled() *OIDCService_OIDCEnabled_Call {
200+ return &OIDCService_OIDCEnabled_Call{Call: _e.mock.On("OIDCEnabled")}
201+}
202+
203+func (_c *OIDCService_OIDCEnabled_Call) Run(run func()) *OIDCService_OIDCEnabled_Call {
204+ _c.Call.Run(func(args mock.Arguments) {
205+ run()
206+ })
207+ return _c
208+}
209+
210+func (_c *OIDCService_OIDCEnabled_Call) Return(b bool) *OIDCService_OIDCEnabled_Call {
211+ _c.Call.Return(b)
212+ return _c
213+}
214+
215+func (_c *OIDCService_OIDCEnabled_Call) RunAndReturn(run func() bool) *OIDCService_OIDCEnabled_Call {
216+ _c.Call.Return(run)
217+ return _c
218+}
219+
220+// OIDCKeys provides a mock function for the type OIDCService
221+func (_mock *OIDCService) OIDCKeys() ([]byte, error) {
222+ ret := _mock.Called()
223+
224+ if len(ret) == 0 {
225+ panic("no return value specified for OIDCKeys")
226+ }
227+
228+ var r0 []byte
229+ var r1 error
230+ if returnFunc, ok := ret.Get(0).(func() ([]byte, error)); ok {
231+ return returnFunc()
232+ }
233+ if returnFunc, ok := ret.Get(0).(func() []byte); ok {
234+ r0 = returnFunc()
235+ } else {
236+ if ret.Get(0) != nil {
237+ r0 = ret.Get(0).([]byte)
238+ }
239+ }
240+ if returnFunc, ok := ret.Get(1).(func() error); ok {
241+ r1 = returnFunc()
242+ } else {
243+ r1 = ret.Error(1)
244+ }
245+ return r0, r1
246+}
247+
248+// OIDCService_OIDCKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OIDCKeys'
249+type OIDCService_OIDCKeys_Call struct {
250+ *mock.Call
251+}
252+
253+// OIDCKeys is a helper method to define mock.On call
254+func (_e *OIDCService_Expecter) OIDCKeys() *OIDCService_OIDCKeys_Call {
255+ return &OIDCService_OIDCKeys_Call{Call: _e.mock.On("OIDCKeys")}
256+}
257+
258+func (_c *OIDCService_OIDCKeys_Call) Run(run func()) *OIDCService_OIDCKeys_Call {
259+ _c.Call.Run(func(args mock.Arguments) {
260+ run()
261+ })
262+ return _c
263+}
264+
265+func (_c *OIDCService_OIDCKeys_Call) Return(bytes []byte, err error) *OIDCService_OIDCKeys_Call {
266+ _c.Call.Return(bytes, err)
267+ return _c
268+}
269+
270+func (_c *OIDCService_OIDCKeys_Call) RunAndReturn(run func() ([]byte, error)) *OIDCService_OIDCKeys_Call {
271+ _c.Call.Return(run)
272+ return _c
273+}
@@ -0,0 +1,273 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewOIDCService creates a new instance of OIDCService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewOIDCService(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *OIDCService {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &OIDCService{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// OIDCService is an autogenerated mock type for the OIDCService type
37+type OIDCService struct {
38+ mock.Mock
39+}
40+
41+type OIDCService_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *OIDCService) EXPECT() *OIDCService_Expecter {
46+ return &OIDCService_Expecter{mock: &_m.Mock}
47+}
48+
49+// IDToken provides a mock function for the type OIDCService
50+func (_mock *OIDCService) IDToken(ctx context.Context, secret string, audience string) (string, error) {
51+ ret := _mock.Called(ctx, secret, audience)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for IDToken")
55+ }
56+
57+ var r0 string
58+ var r1 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (string, error)); ok {
60+ return returnFunc(ctx, secret, audience)
61+ }
62+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) string); ok {
63+ r0 = returnFunc(ctx, secret, audience)
64+ } else {
65+ r0 = ret.Get(0).(string)
66+ }
67+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
68+ r1 = returnFunc(ctx, secret, audience)
69+ } else {
70+ r1 = ret.Error(1)
71+ }
72+ return r0, r1
73+}
74+
75+// OIDCService_IDToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IDToken'
76+type OIDCService_IDToken_Call struct {
77+ *mock.Call
78+}
79+
80+// IDToken is a helper method to define mock.On call
81+// - ctx context.Context
82+// - secret string
83+// - audience string
84+func (_e *OIDCService_Expecter) IDToken(ctx any, secret any, audience any) *OIDCService_IDToken_Call {
85+ return &OIDCService_IDToken_Call{Call: _e.mock.On("IDToken", ctx, secret, audience)}
86+}
87+
88+func (_c *OIDCService_IDToken_Call) Run(run func(ctx context.Context, secret string, audience string)) *OIDCService_IDToken_Call {
89+ _c.Call.Run(func(args mock.Arguments) {
90+ var arg0 context.Context
91+ if args[0] != nil {
92+ arg0 = args[0].(context.Context)
93+ }
94+ var arg1 string
95+ if args[1] != nil {
96+ arg1 = args[1].(string)
97+ }
98+ var arg2 string
99+ if args[2] != nil {
100+ arg2 = args[2].(string)
101+ }
102+ run(
103+ arg0,
104+ arg1,
105+ arg2,
106+ )
107+ })
108+ return _c
109+}
110+
111+func (_c *OIDCService_IDToken_Call) Return(s string, err error) *OIDCService_IDToken_Call {
112+ _c.Call.Return(s, err)
113+ return _c
114+}
115+
116+func (_c *OIDCService_IDToken_Call) RunAndReturn(run func(ctx context.Context, secret string, audience string) (string, error)) *OIDCService_IDToken_Call {
117+ _c.Call.Return(run)
118+ return _c
119+}
120+
121+// OIDCDiscovery provides a mock function for the type OIDCService
122+func (_mock *OIDCService) OIDCDiscovery() ([]byte, error) {
123+ ret := _mock.Called()
124+
125+ if len(ret) == 0 {
126+ panic("no return value specified for OIDCDiscovery")
127+ }
128+
129+ var r0 []byte
130+ var r1 error
131+ if returnFunc, ok := ret.Get(0).(func() ([]byte, error)); ok {
132+ return returnFunc()
133+ }
134+ if returnFunc, ok := ret.Get(0).(func() []byte); ok {
135+ r0 = returnFunc()
136+ } else {
137+ if ret.Get(0) != nil {
138+ r0 = ret.Get(0).([]byte)
139+ }
140+ }
141+ if returnFunc, ok := ret.Get(1).(func() error); ok {
142+ r1 = returnFunc()
143+ } else {
144+ r1 = ret.Error(1)
145+ }
146+ return r0, r1
147+}
148+
149+// OIDCService_OIDCDiscovery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OIDCDiscovery'
150+type OIDCService_OIDCDiscovery_Call struct {
151+ *mock.Call
152+}
153+
154+// OIDCDiscovery is a helper method to define mock.On call
155+func (_e *OIDCService_Expecter) OIDCDiscovery() *OIDCService_OIDCDiscovery_Call {
156+ return &OIDCService_OIDCDiscovery_Call{Call: _e.mock.On("OIDCDiscovery")}
157+}
158+
159+func (_c *OIDCService_OIDCDiscovery_Call) Run(run func()) *OIDCService_OIDCDiscovery_Call {
160+ _c.Call.Run(func(args mock.Arguments) {
161+ run()
162+ })
163+ return _c
164+}
165+
166+func (_c *OIDCService_OIDCDiscovery_Call) Return(bytes []byte, err error) *OIDCService_OIDCDiscovery_Call {
167+ _c.Call.Return(bytes, err)
168+ return _c
169+}
170+
171+func (_c *OIDCService_OIDCDiscovery_Call) RunAndReturn(run func() ([]byte, error)) *OIDCService_OIDCDiscovery_Call {
172+ _c.Call.Return(run)
173+ return _c
174+}
175+
176+// OIDCEnabled provides a mock function for the type OIDCService
177+func (_mock *OIDCService) OIDCEnabled() bool {
178+ ret := _mock.Called()
179+
180+ if len(ret) == 0 {
181+ panic("no return value specified for OIDCEnabled")
182+ }
183+
184+ var r0 bool
185+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
186+ r0 = returnFunc()
187+ } else {
188+ r0 = ret.Get(0).(bool)
189+ }
190+ return r0
191+}
192+
193+// OIDCService_OIDCEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OIDCEnabled'
194+type OIDCService_OIDCEnabled_Call struct {
195+ *mock.Call
196+}
197+
198+// OIDCEnabled is a helper method to define mock.On call
199+func (_e *OIDCService_Expecter) OIDCEnabled() *OIDCService_OIDCEnabled_Call {
200+ return &OIDCService_OIDCEnabled_Call{Call: _e.mock.On("OIDCEnabled")}
201+}
202+
203+func (_c *OIDCService_OIDCEnabled_Call) Run(run func()) *OIDCService_OIDCEnabled_Call {
204+ _c.Call.Run(func(args mock.Arguments) {
205+ run()
206+ })
207+ return _c
208+}
209+
210+func (_c *OIDCService_OIDCEnabled_Call) Return(b bool) *OIDCService_OIDCEnabled_Call {
211+ _c.Call.Return(b)
212+ return _c
213+}
214+
215+func (_c *OIDCService_OIDCEnabled_Call) RunAndReturn(run func() bool) *OIDCService_OIDCEnabled_Call {
216+ _c.Call.Return(run)
217+ return _c
218+}
219+
220+// OIDCKeys provides a mock function for the type OIDCService
221+func (_mock *OIDCService) OIDCKeys() ([]byte, error) {
222+ ret := _mock.Called()
223+
224+ if len(ret) == 0 {
225+ panic("no return value specified for OIDCKeys")
226+ }
227+
228+ var r0 []byte
229+ var r1 error
230+ if returnFunc, ok := ret.Get(0).(func() ([]byte, error)); ok {
231+ return returnFunc()
232+ }
233+ if returnFunc, ok := ret.Get(0).(func() []byte); ok {
234+ r0 = returnFunc()
235+ } else {
236+ if ret.Get(0) != nil {
237+ r0 = ret.Get(0).([]byte)
238+ }
239+ }
240+ if returnFunc, ok := ret.Get(1).(func() error); ok {
241+ r1 = returnFunc()
242+ } else {
243+ r1 = ret.Error(1)
244+ }
245+ return r0, r1
246+}
247+
248+// OIDCService_OIDCKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OIDCKeys'
249+type OIDCService_OIDCKeys_Call struct {
250+ *mock.Call
251+}
252+
253+// OIDCKeys is a helper method to define mock.On call
254+func (_e *OIDCService_Expecter) OIDCKeys() *OIDCService_OIDCKeys_Call {
255+ return &OIDCService_OIDCKeys_Call{Call: _e.mock.On("OIDCKeys")}
256+}
257+
258+func (_c *OIDCService_OIDCKeys_Call) Run(run func()) *OIDCService_OIDCKeys_Call {
259+ _c.Call.Run(func(args mock.Arguments) {
260+ run()
261+ })
262+ return _c
263+}
264+
265+func (_c *OIDCService_OIDCKeys_Call) Return(bytes []byte, err error) *OIDCService_OIDCKeys_Call {
266+ _c.Call.Return(bytes, err)
267+ return _c
268+}
269+
270+func (_c *OIDCService_OIDCKeys_Call) RunAndReturn(run func() ([]byte, error)) *OIDCService_OIDCKeys_Call {
271+ _c.Call.Return(run)
272+ return _c
273+}
A internal/ports/oidc.go +58 -0
@@ -0,0 +1,58 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ports
4+
5+import (
6+ "context"
7+ "time"
8+
9+ "bnema.dev/ermite/internal/domain"
10+)
11+
12+// OIDCKeyStore keeps the signing keys of the ID tokens of CI jobs.
13+// *kvstore.Store implements it.
14+type OIDCKeyStore interface {
15+ // ListOIDCKeys returns every stored key, oldest first.
16+ ListOIDCKeys(ctx context.Context) ([]domain.OIDCKey, error)
17+ // SaveOIDCKeys writes the keys of put (created or replaced) and deletes the
18+ // keys whose ids are in drop, in one batch.
19+ SaveOIDCKeys(ctx context.Context, put []domain.OIDCKey, drop []string) error
20+}
21+
22+// OIDCService is the OIDC provider of CI jobs, served under /-/oidc: the
23+// discovery document, the JWKS and the token endpoint. *usecase.CI implements
24+// it.
25+type OIDCService interface {
26+ // OIDCEnabled reports whether the server issues ID tokens (it has a secrets
27+ // key to seal its signing key with). When it does not, the other methods
28+ // give domain.ErrNotFound.
29+ OIDCEnabled() bool
30+ // OIDCDiscovery returns the JSON of the OpenID Provider configuration.
31+ OIDCDiscovery() ([]byte, error)
32+ // OIDCKeys returns the JSON of the JWKS: the key that signs and the keys
33+ // replaced less than domain.OIDCKeyRetention ago.
34+ OIDCKeys() ([]byte, error)
35+ // IDToken returns a signed ID token of the job whose request token is
36+ // secret, for audience ("" for the default). A secret that is not the
37+ // request token of a job that is assigned or running gives
38+ // domain.ErrUnauthenticated; an audience that is too long gives a
39+ // domain.InvalidFieldsError.
40+ IDToken(ctx context.Context, secret, audience string) (string, error)
41+}
42+
43+// OIDCRotation is the outcome of a key rotation.
44+type OIDCRotation struct {
45+ Issuer string
46+ // Kid is the id of the new key, RetiredKid the one it replaced ("" for
47+ // none), published until RetiredUntil.
48+ Kid string
49+ RetiredKid string
50+ RetiredUntil time.Time
51+}
52+
53+// OIDCKeyService manages the signing key of the ID tokens. *usecase.OIDC
54+// implements it.
55+type OIDCKeyService interface {
56+ // RotateKey replaces the signing key by a new one. It needs owner rights.
57+ RotateKey(ctx context.Context, p domain.Principal) (OIDCRotation, error)
58+}
@@ -0,0 +1,58 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ports
4+
5+import (
6+ "context"
7+ "time"
8+
9+ "bnema.dev/ermite/internal/domain"
10+)
11+
12+// OIDCKeyStore keeps the signing keys of the ID tokens of CI jobs.
13+// *kvstore.Store implements it.
14+type OIDCKeyStore interface {
15+ // ListOIDCKeys returns every stored key, oldest first.
16+ ListOIDCKeys(ctx context.Context) ([]domain.OIDCKey, error)
17+ // SaveOIDCKeys writes the keys of put (created or replaced) and deletes the
18+ // keys whose ids are in drop, in one batch.
19+ SaveOIDCKeys(ctx context.Context, put []domain.OIDCKey, drop []string) error
20+}
21+
22+// OIDCService is the OIDC provider of CI jobs, served under /-/oidc: the
23+// discovery document, the JWKS and the token endpoint. *usecase.CI implements
24+// it.
25+type OIDCService interface {
26+ // OIDCEnabled reports whether the server issues ID tokens (it has a secrets
27+ // key to seal its signing key with). When it does not, the other methods
28+ // give domain.ErrNotFound.
29+ OIDCEnabled() bool
30+ // OIDCDiscovery returns the JSON of the OpenID Provider configuration.
31+ OIDCDiscovery() ([]byte, error)
32+ // OIDCKeys returns the JSON of the JWKS: the key that signs and the keys
33+ // replaced less than domain.OIDCKeyRetention ago.
34+ OIDCKeys() ([]byte, error)
35+ // IDToken returns a signed ID token of the job whose request token is
36+ // secret, for audience ("" for the default). A secret that is not the
37+ // request token of a job that is assigned or running gives
38+ // domain.ErrUnauthenticated; an audience that is too long gives a
39+ // domain.InvalidFieldsError.
40+ IDToken(ctx context.Context, secret, audience string) (string, error)
41+}
42+
43+// OIDCRotation is the outcome of a key rotation.
44+type OIDCRotation struct {
45+ Issuer string
46+ // Kid is the id of the new key, RetiredKid the one it replaced ("" for
47+ // none), published until RetiredUntil.
48+ Kid string
49+ RetiredKid string
50+ RetiredUntil time.Time
51+}
52+
53+// OIDCKeyService manages the signing key of the ID tokens. *usecase.OIDC
54+// implements it.
55+type OIDCKeyService interface {
56+ // RotateKey replaces the signing key by a new one. It needs owner rights.
57+ RotateKey(ctx context.Context, p domain.Principal) (OIDCRotation, error)
58+}
M internal/usecase/ci.go +4 -0
@@ -106,6 +106,9 @@ type CIConfig struct {
106106 // read when an action of another repository is resolved for the run. Nil
107107 // reads nothing for a token (fail closed).
108108 Tokens ports.TokenStore
109+ // OIDC signs the ID tokens of the jobs (CI-4); nil issues none: jobs get no
110+ // token endpoint, and the provider is not served.
111+ OIDC *OIDC
109112 // Settings resolves the variables and secrets of a job (CI-3). Nil gives
110113 // jobs none, and an empty "vars" context. Leave it unset rather than
111114 // assigning a typed nil pointer.
@@ -267,6 +270,7 @@ type pollRecord struct {
267270 ref string
268271 token string
269272 ghToken string
273+ idToken string
270274 hash domain.TokenHash
271275 }
272276
@@ -106,6 +106,9 @@ type CIConfig struct {
106 // read when an action of another repository is resolved for the run. Nil106 // read when an action of another repository is resolved for the run. Nil
107 // reads nothing for a token (fail closed).107 // reads nothing for a token (fail closed).
108 Tokens ports.TokenStore108 Tokens ports.TokenStore
109+ // OIDC signs the ID tokens of the jobs (CI-4); nil issues none: jobs get no
110+ // token endpoint, and the provider is not served.
111+ OIDC *OIDC
109 // Settings resolves the variables and secrets of a job (CI-3). Nil gives112 // Settings resolves the variables and secrets of a job (CI-3). Nil gives
110 // jobs none, and an empty "vars" context. Leave it unset rather than113 // jobs none, and an empty "vars" context. Leave it unset rather than
111 // assigning a typed nil pointer.114 // assigning a typed nil pointer.
@@ -267,6 +270,7 @@ type pollRecord struct {
267 ref string270 ref string
268 token string271 token string
269 ghToken string272 ghToken string
273+ idToken string
270 hash domain.TokenHash274 hash domain.TokenHash
271 }275 }
272 276
M internal/usecase/ci_github.go +1 -1
@@ -321,7 +321,7 @@ func (c *CI) githubContext(repo domain.Repo, run domain.CIRun, e ghEvent) string
321321 {"job", ""}, {"ref", run.Ref}, {"sha", run.Sha}, {"repository", u.full}, {"repository_owner", c.cfg.Owner},
322322 {"repository_owner_id", strconv.FormatInt(owner.id, 10)}, {"repositoryUrl", "git://" + u.host + "/" + u.full + ".git"},
323323 {"run_id", strconv.FormatInt(run.ID, 10)}, {"run_number", strconv.FormatInt(run.Number, 10)},
324- {"retention_days", strconv.Itoa(c.retentionDays())}, {"run_attempt", "1"},
324+ {"retention_days", strconv.Itoa(c.retentionDays())}, {"run_attempt", strconv.Itoa(int(max(run.Attempt, 1)))},
325325 {"artifact_cache_size_limit", ghCacheLimit}, {"repository_visibility", string(repo.Visibility)},
326326 {"actor_id", strconv.FormatInt(id.id, 10)}, {"actor", id.login}, {"workflow", run.WorkflowName},
327327 {"head_ref", e.headRef}, {"base_ref", e.baseRef}, {"event_name", e.name}, {"server_url", u.server},
@@ -321,7 +321,7 @@ func (c *CI) githubContext(repo domain.Repo, run domain.CIRun, e ghEvent) string
321 {"job", ""}, {"ref", run.Ref}, {"sha", run.Sha}, {"repository", u.full}, {"repository_owner", c.cfg.Owner},321 {"job", ""}, {"ref", run.Ref}, {"sha", run.Sha}, {"repository", u.full}, {"repository_owner", c.cfg.Owner},
322 {"repository_owner_id", strconv.FormatInt(owner.id, 10)}, {"repositoryUrl", "git://" + u.host + "/" + u.full + ".git"},322 {"repository_owner_id", strconv.FormatInt(owner.id, 10)}, {"repositoryUrl", "git://" + u.host + "/" + u.full + ".git"},
323 {"run_id", strconv.FormatInt(run.ID, 10)}, {"run_number", strconv.FormatInt(run.Number, 10)},323 {"run_id", strconv.FormatInt(run.ID, 10)}, {"run_number", strconv.FormatInt(run.Number, 10)},
324- {"retention_days", strconv.Itoa(c.retentionDays())}, {"run_attempt", "1"},324+ {"retention_days", strconv.Itoa(c.retentionDays())}, {"run_attempt", strconv.Itoa(int(max(run.Attempt, 1)))},
325 {"artifact_cache_size_limit", ghCacheLimit}, {"repository_visibility", string(repo.Visibility)},325 {"artifact_cache_size_limit", ghCacheLimit}, {"repository_visibility", string(repo.Visibility)},
326 {"actor_id", strconv.FormatInt(id.id, 10)}, {"actor", id.login}, {"workflow", run.WorkflowName},326 {"actor_id", strconv.FormatInt(id.id, 10)}, {"actor", id.login}, {"workflow", run.WorkflowName},
327 {"head_ref", e.headRef}, {"base_ref", e.baseRef}, {"event_name", e.name}, {"server_url", u.server},327 {"head_ref", e.headRef}, {"base_ref", e.baseRef}, {"event_name", e.name}, {"server_url", u.server},
A internal/usecase/ci_oidc.go +193 -0
@@ -0,0 +1,193 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto/rand"
9+ "encoding/json"
10+ "errors"
11+ "fmt"
12+ "strconv"
13+ "time"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// The ID tokens of CI jobs (CI-4, D5). A job that may have them (a trusted run
20+// and id-token: write, domain.IDTokenAllowed) gets with its assignment the
21+// token endpoint and a request token of its own: a job repository token
22+// (CIRepoToken.OIDC) that lives and dies with the job as its github.token does,
23+// and that nothing but the token endpoint accepts. The runner gives both to
24+// the steps as ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN,
25+// as GitHub's runner does; never ACTIONS_RUNTIME_TOKEN.
26+
27+var _ ports.OIDCService = (*CI)(nil)
28+
29+// issueIDTokenRequest adds the ID token request token of j to the next flush
30+// and returns its secret, or "" when the job may not request ID tokens or the
31+// server issues none. j must be assigned (its AssignedAt is set).
32+func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string {
33+ c := rs.c
34+ if c.cfg.OIDC == nil || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) {
35+ return ""
36+ }
37+ now := c.now()
38+ at := *j
39+ if at.AssignedAt.IsZero() {
40+ at.AssignedAt = now
41+ }
42+ secret := newToken(domain.OIDCRequestTokenPrefix)
43+ t := c.repoTokenExpiry(at)
44+ t.ID, t.Hash, t.Repo, t.OIDC = newRepoTokenID(), domain.HashTokenSecret(secret), rs.run.Repo, true
45+ t.RunID, t.RunAttempt, t.JobID, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, now
46+ rs.tokens = append(rs.tokens, t)
47+ return secret
48+}
49+
50+// OIDCEnabled reports whether the server issues ID tokens.
51+func (c *CI) OIDCEnabled() bool { return c.cfg.OIDC != nil }
52+
53+// OIDCDiscovery returns the OpenID Provider configuration.
54+func (c *CI) OIDCDiscovery() ([]byte, error) {
55+ if c.cfg.OIDC == nil {
56+ return nil, domain.ErrNotFound
57+ }
58+ return c.cfg.OIDC.Discovery()
59+}
60+
61+// OIDCKeys returns the JWKS.
62+func (c *CI) OIDCKeys() ([]byte, error) {
63+ if c.cfg.OIDC == nil {
64+ return nil, domain.ErrNotFound
65+ }
66+ return c.cfg.OIDC.JWKS()
67+}
68+
69+// IDToken signs an ID token for the job whose request token is secret. The
70+// token must exist (it is deleted when the job ends), not have expired, and
71+// its job must be assigned or running, still allowed to have ID tokens.
72+// audience "" is the default, "<public URL>/<owner>" as GitHub's
73+// "https://github.com/<owner>".
74+func (c *CI) IDToken(ctx context.Context, secret, audience string) (string, error) {
75+ o := c.cfg.OIDC
76+ if o == nil {
77+ return "", domain.ErrNotFound
78+ }
79+ if len(audience) > domain.MaxOIDCAudienceLen {
80+ return "", &domain.InvalidFieldsError{Fields: []domain.FieldViolation{{Field: "audience", Reason: fmt.Sprintf("must be at most %d bytes", domain.MaxOIDCAudienceLen)}}}
81+ }
82+ if !domain.IsOIDCRequestToken(secret) {
83+ return "", domain.ErrUnauthenticated
84+ }
85+ t, err := o.cfg.Tokens.FindRepoTokenByHash(ctx, domain.HashTokenSecret(secret))
86+ switch {
87+ case errors.Is(err, domain.ErrNotFound):
88+ return "", domain.ErrUnauthenticated
89+ case err != nil:
90+ return "", fmt.Errorf("find the id token request token: %w", err)
91+ }
92+ now := c.now()
93+ if !t.OIDC || t.Expired(now) {
94+ return "", domain.ErrUnauthenticated
95+ }
96+ rs, err := c.loadRun(ctx, t.RunID, t.RunAttempt)
97+ switch {
98+ case errors.Is(err, domain.ErrNotFound):
99+ return "", domain.ErrUnauthenticated
100+ case err != nil:
101+ return "", err
102+ }
103+ j := rs.find(t.JobID)
104+ if j == nil || !j.Active() || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) {
105+ return "", domain.ErrUnauthenticated
106+ }
107+ if audience == "" {
108+ audience = c.cfg.PublicURL + "/" + c.cfg.Owner
109+ }
110+ claims, err := idTokenClaims(j.ContextsJSON, j.Environment, idTokenMeta{
111+ issuer: o.Issuer(), audience: audience, jti: newJTI(), now: now,
112+ })
113+ if err != nil {
114+ return "", err
115+ }
116+ return o.sign(claims)
117+}
118+
119+// idTokenMeta are the claims of a token that do not come from the job.
120+type idTokenMeta struct {
121+ issuer, audience, jti string
122+ now time.Time
123+}
124+
125+// idTokenClaims builds the claims of a job's ID token from the contexts the
126+// job was given (Job.contexts): the github context for the run, the job
127+// context for the workflow that defines the job (the called one for a job of a
128+// reusable workflow: job_workflow_ref), as GitHub's tokens. The order is the one
129+// of GitHub's example token.
130+func idTokenClaims(contextsJSON, environment string, t idTokenMeta) (ordered, error) {
131+ var ctxs struct {
132+ GitHub map[string]any `json:"github"`
133+ Job map[string]any `json:"job"`
134+ }
135+ dec := json.NewDecoder(bytes.NewReader([]byte(contextsJSON)))
136+ dec.UseNumber()
137+ if err := dec.Decode(&ctxs); err != nil {
138+ return nil, fmt.Errorf("the contexts of the job are not valid: %w", err)
139+ }
140+ gh := func(k string) string { return claimString(ctxs.GitHub[k]) }
141+ job := func(k string) string { return claimString(ctxs.Job[k]) }
142+ claims := ordered{
143+ {"jti", t.jti},
144+ {"sub", domain.OIDCSubject(gh("repository"), gh("event_name"), environment, gh("ref"))},
145+ }
146+ if environment != "" {
147+ claims = append(claims, pair{"environment", environment})
148+ }
149+ iat := t.now.Unix()
150+ claims = append(claims, ordered{
151+ {"aud", t.audience},
152+ {"ref", gh("ref")}, {"sha", gh("sha")},
153+ {"repository", gh("repository")}, {"repository_owner", gh("repository_owner")},
154+ {"repository_owner_id", gh("repository_owner_id")},
155+ {"run_id", gh("run_id")}, {"run_number", gh("run_number")}, {"run_attempt", gh("run_attempt")},
156+ {"repository_visibility", gh("repository_visibility")}, {"repository_id", gh("repository_id")},
157+ {"actor_id", gh("actor_id")}, {"actor", gh("actor")}, {"workflow", gh("workflow")},
158+ {"head_ref", gh("head_ref")}, {"base_ref", gh("base_ref")}, {"event_name", gh("event_name")},
159+ {"ref_protected", gh("ref_protected")}, {"ref_type", gh("ref_type")},
160+ {"workflow_ref", gh("workflow_ref")}, {"workflow_sha", gh("workflow_sha")},
161+ {"job_workflow_ref", job("workflow_ref")}, {"job_workflow_sha", job("workflow_sha")},
162+ {"check_run_id", job("check_run_id")},
163+ {"runner_environment", domain.OIDCRunnerEnvironment},
164+ {"iss", t.issuer},
165+ {"nbf", iat - int64(domain.OIDCNotBeforeSkew/time.Second)},
166+ {"exp", iat + int64(domain.OIDCTokenLifetime/time.Second)},
167+ {"iat", iat},
168+ }...)
169+ return claims, nil
170+}
171+
172+// claimString is a context value as GitHub puts it in a claim: a string, numbers
173+// and booleans written out, nothing for null.
174+func claimString(v any) string {
175+ switch v := v.(type) {
176+ case string:
177+ return v
178+ case json.Number:
179+ return v.String()
180+ case bool:
181+ return strconv.FormatBool(v)
182+ }
183+ return ""
184+}
185+
186+// newJTI is a random UUID (version 4), the shape of GitHub's jti.
187+func newJTI() string {
188+ var b [16]byte
189+ _, _ = rand.Read(b[:])
190+ b[6] = b[6]&0x0f | 0x40
191+ b[8] = b[8]&0x3f | 0x80
192+ return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
193+}
@@ -0,0 +1,193 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto/rand"
9+ "encoding/json"
10+ "errors"
11+ "fmt"
12+ "strconv"
13+ "time"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// The ID tokens of CI jobs (CI-4, D5). A job that may have them (a trusted run
20+// and id-token: write, domain.IDTokenAllowed) gets with its assignment the
21+// token endpoint and a request token of its own: a job repository token
22+// (CIRepoToken.OIDC) that lives and dies with the job as its github.token does,
23+// and that nothing but the token endpoint accepts. The runner gives both to
24+// the steps as ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN,
25+// as GitHub's runner does; never ACTIONS_RUNTIME_TOKEN.
26+
27+var _ ports.OIDCService = (*CI)(nil)
28+
29+// issueIDTokenRequest adds the ID token request token of j to the next flush
30+// and returns its secret, or "" when the job may not request ID tokens or the
31+// server issues none. j must be assigned (its AssignedAt is set).
32+func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string {
33+ c := rs.c
34+ if c.cfg.OIDC == nil || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) {
35+ return ""
36+ }
37+ now := c.now()
38+ at := *j
39+ if at.AssignedAt.IsZero() {
40+ at.AssignedAt = now
41+ }
42+ secret := newToken(domain.OIDCRequestTokenPrefix)
43+ t := c.repoTokenExpiry(at)
44+ t.ID, t.Hash, t.Repo, t.OIDC = newRepoTokenID(), domain.HashTokenSecret(secret), rs.run.Repo, true
45+ t.RunID, t.RunAttempt, t.JobID, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, now
46+ rs.tokens = append(rs.tokens, t)
47+ return secret
48+}
49+
50+// OIDCEnabled reports whether the server issues ID tokens.
51+func (c *CI) OIDCEnabled() bool { return c.cfg.OIDC != nil }
52+
53+// OIDCDiscovery returns the OpenID Provider configuration.
54+func (c *CI) OIDCDiscovery() ([]byte, error) {
55+ if c.cfg.OIDC == nil {
56+ return nil, domain.ErrNotFound
57+ }
58+ return c.cfg.OIDC.Discovery()
59+}
60+
61+// OIDCKeys returns the JWKS.
62+func (c *CI) OIDCKeys() ([]byte, error) {
63+ if c.cfg.OIDC == nil {
64+ return nil, domain.ErrNotFound
65+ }
66+ return c.cfg.OIDC.JWKS()
67+}
68+
69+// IDToken signs an ID token for the job whose request token is secret. The
70+// token must exist (it is deleted when the job ends), not have expired, and
71+// its job must be assigned or running, still allowed to have ID tokens.
72+// audience "" is the default, "<public URL>/<owner>" as GitHub's
73+// "https://github.com/<owner>".
74+func (c *CI) IDToken(ctx context.Context, secret, audience string) (string, error) {
75+ o := c.cfg.OIDC
76+ if o == nil {
77+ return "", domain.ErrNotFound
78+ }
79+ if len(audience) > domain.MaxOIDCAudienceLen {
80+ return "", &domain.InvalidFieldsError{Fields: []domain.FieldViolation{{Field: "audience", Reason: fmt.Sprintf("must be at most %d bytes", domain.MaxOIDCAudienceLen)}}}
81+ }
82+ if !domain.IsOIDCRequestToken(secret) {
83+ return "", domain.ErrUnauthenticated
84+ }
85+ t, err := o.cfg.Tokens.FindRepoTokenByHash(ctx, domain.HashTokenSecret(secret))
86+ switch {
87+ case errors.Is(err, domain.ErrNotFound):
88+ return "", domain.ErrUnauthenticated
89+ case err != nil:
90+ return "", fmt.Errorf("find the id token request token: %w", err)
91+ }
92+ now := c.now()
93+ if !t.OIDC || t.Expired(now) {
94+ return "", domain.ErrUnauthenticated
95+ }
96+ rs, err := c.loadRun(ctx, t.RunID, t.RunAttempt)
97+ switch {
98+ case errors.Is(err, domain.ErrNotFound):
99+ return "", domain.ErrUnauthenticated
100+ case err != nil:
101+ return "", err
102+ }
103+ j := rs.find(t.JobID)
104+ if j == nil || !j.Active() || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) {
105+ return "", domain.ErrUnauthenticated
106+ }
107+ if audience == "" {
108+ audience = c.cfg.PublicURL + "/" + c.cfg.Owner
109+ }
110+ claims, err := idTokenClaims(j.ContextsJSON, j.Environment, idTokenMeta{
111+ issuer: o.Issuer(), audience: audience, jti: newJTI(), now: now,
112+ })
113+ if err != nil {
114+ return "", err
115+ }
116+ return o.sign(claims)
117+}
118+
119+// idTokenMeta are the claims of a token that do not come from the job.
120+type idTokenMeta struct {
121+ issuer, audience, jti string
122+ now time.Time
123+}
124+
125+// idTokenClaims builds the claims of a job's ID token from the contexts the
126+// job was given (Job.contexts): the github context for the run, the job
127+// context for the workflow that defines the job (the called one for a job of a
128+// reusable workflow: job_workflow_ref), as GitHub's tokens. The order is the one
129+// of GitHub's example token.
130+func idTokenClaims(contextsJSON, environment string, t idTokenMeta) (ordered, error) {
131+ var ctxs struct {
132+ GitHub map[string]any `json:"github"`
133+ Job map[string]any `json:"job"`
134+ }
135+ dec := json.NewDecoder(bytes.NewReader([]byte(contextsJSON)))
136+ dec.UseNumber()
137+ if err := dec.Decode(&ctxs); err != nil {
138+ return nil, fmt.Errorf("the contexts of the job are not valid: %w", err)
139+ }
140+ gh := func(k string) string { return claimString(ctxs.GitHub[k]) }
141+ job := func(k string) string { return claimString(ctxs.Job[k]) }
142+ claims := ordered{
143+ {"jti", t.jti},
144+ {"sub", domain.OIDCSubject(gh("repository"), gh("event_name"), environment, gh("ref"))},
145+ }
146+ if environment != "" {
147+ claims = append(claims, pair{"environment", environment})
148+ }
149+ iat := t.now.Unix()
150+ claims = append(claims, ordered{
151+ {"aud", t.audience},
152+ {"ref", gh("ref")}, {"sha", gh("sha")},
153+ {"repository", gh("repository")}, {"repository_owner", gh("repository_owner")},
154+ {"repository_owner_id", gh("repository_owner_id")},
155+ {"run_id", gh("run_id")}, {"run_number", gh("run_number")}, {"run_attempt", gh("run_attempt")},
156+ {"repository_visibility", gh("repository_visibility")}, {"repository_id", gh("repository_id")},
157+ {"actor_id", gh("actor_id")}, {"actor", gh("actor")}, {"workflow", gh("workflow")},
158+ {"head_ref", gh("head_ref")}, {"base_ref", gh("base_ref")}, {"event_name", gh("event_name")},
159+ {"ref_protected", gh("ref_protected")}, {"ref_type", gh("ref_type")},
160+ {"workflow_ref", gh("workflow_ref")}, {"workflow_sha", gh("workflow_sha")},
161+ {"job_workflow_ref", job("workflow_ref")}, {"job_workflow_sha", job("workflow_sha")},
162+ {"check_run_id", job("check_run_id")},
163+ {"runner_environment", domain.OIDCRunnerEnvironment},
164+ {"iss", t.issuer},
165+ {"nbf", iat - int64(domain.OIDCNotBeforeSkew/time.Second)},
166+ {"exp", iat + int64(domain.OIDCTokenLifetime/time.Second)},
167+ {"iat", iat},
168+ }...)
169+ return claims, nil
170+}
171+
172+// claimString is a context value as GitHub puts it in a claim: a string, numbers
173+// and booleans written out, nothing for null.
174+func claimString(v any) string {
175+ switch v := v.(type) {
176+ case string:
177+ return v
178+ case json.Number:
179+ return v.String()
180+ case bool:
181+ return strconv.FormatBool(v)
182+ }
183+ return ""
184+}
185+
186+// newJTI is a random UUID (version 4), the shape of GitHub's jti.
187+func newJTI() string {
188+ var b [16]byte
189+ _, _ = rand.Read(b[:])
190+ b[6] = b[6]&0x0f | 0x40
191+ b[8] = b[8]&0x3f | 0x80
192+ return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
193+}
M internal/usecase/ci_runner.go +18 -6
@@ -216,7 +216,7 @@ func (c *CI) repeatPoll(ctx context.Context, runner domain.CIRunner, pollID stri
216216 if err != nil {
217217 return err
218218 }
219- out = c.runnerJob(rs, *j, rec.token, rec.ghToken, settings)
219+ out = c.runnerJob(rs, *j, jobSecrets{token: rec.token, ghToken: rec.ghToken, idToken: rec.idToken}, settings)
220220 return nil
221221 })
222222 switch {
@@ -238,7 +238,7 @@ func (c *CI) repeatPoll(ctx context.Context, runner domain.CIRunner, pollID stri
238238 //
239239 // TODO(ci3-c2): the runner writes the "Set up job" log; it states there that
240240 // secrets were withheld when Trusted is false (the server adds no setup line).
241-func (c *CI) runnerJob(rs *runState, j domain.CIJob, token, ghToken string, settings ports.JobSettings) *ports.RunnerJob {
241+func (c *CI) runnerJob(rs *runState, j domain.CIJob, sec jobSecrets, settings ports.JobSettings) *ports.RunnerJob {
242242 if settings.Vars != nil {
243243 j.ContextsJSON = withVars(j.ContextsJSON, settings.Vars)
244244 }
@@ -248,13 +248,24 @@ func (c *CI) runnerJob(rs *runState, j domain.CIJob, token, ghToken string, sett
248248 // id in that file.
249249 path, yaml, j.Key = call.Call.Path, call.Call.YAML, j.CallID
250250 }
251- return &ports.RunnerJob{
252- Job: j, Token: token, GithubToken: ghToken, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref,
251+ out := &ports.RunnerJob{
252+ Job: j, Token: sec.token, GithubToken: sec.ghToken, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref,
253253 WorkflowPath: path, WorkflowYAML: yaml,
254254 Environment: j.Environment, Vars: settings.Vars, Secrets: settings.Secrets, Trusted: rs.run.Trusted,
255255 Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted),
256256 Cache: c.cacheScope(rs.run, rs.src.GithubJSON),
257257 }
258+ if sec.idToken != "" && c.cfg.OIDC != nil {
259+ out.IDTokenURL, out.IDTokenRequestToken = c.cfg.OIDC.TokenURL(), sec.idToken
260+ }
261+ return out
262+}
263+
264+// jobSecrets are the secrets issued with an assignment: the job token, the
265+// repository token and the ID token request token ("" when the job gets
266+// none).
267+type jobSecrets struct {
268+ token, ghToken, idToken string
258269 }
259270
260271 // assignTo gives the oldest queued job that matches the labels to the runner,
@@ -283,6 +294,7 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str
283294 }
284295 token := newToken(domain.JobTokenPrefix)
285296 ghToken := rs.issueRepoToken(j)
297+ idToken := rs.issueIDTokenRequest(j)
286298 j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now()
287299 j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{}
288300 j.RunnerID, j.RunnerName, j.Isolation = runner.ID, runner.Name, runner.Isolation
@@ -290,10 +302,10 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str
290302 if err := rs.flush(ctx); err != nil {
291303 return err
292304 }
293- out = c.runnerJob(rs, *j, token, ghToken, settings)
305+ out = c.runnerJob(rs, *j, jobSecrets{token: token, ghToken: ghToken, idToken: idToken}, settings)
294306 if pollID != "" {
295307 c.mu.Lock()
296- c.polls[runner.ID] = pollRecord{pollID: pollID, ref: jobRef(*j), token: token, ghToken: ghToken, hash: j.TokenHash}
308+ c.polls[runner.ID] = pollRecord{pollID: pollID, ref: jobRef(*j), token: token, ghToken: ghToken, idToken: idToken, hash: j.TokenHash}
297309 c.mu.Unlock()
298310 }
299311 return nil
@@ -216,7 +216,7 @@ func (c *CI) repeatPoll(ctx context.Context, runner domain.CIRunner, pollID stri
216 if err != nil {216 if err != nil {
217 return err217 return err
218 }218 }
219- out = c.runnerJob(rs, *j, rec.token, rec.ghToken, settings)219+ out = c.runnerJob(rs, *j, jobSecrets{token: rec.token, ghToken: rec.ghToken, idToken: rec.idToken}, settings)
220 return nil220 return nil
221 })221 })
222 switch {222 switch {
@@ -238,7 +238,7 @@ func (c *CI) repeatPoll(ctx context.Context, runner domain.CIRunner, pollID stri
238 //238 //
239 // TODO(ci3-c2): the runner writes the "Set up job" log; it states there that239 // TODO(ci3-c2): the runner writes the "Set up job" log; it states there that
240 // secrets were withheld when Trusted is false (the server adds no setup line).240 // secrets were withheld when Trusted is false (the server adds no setup line).
241-func (c *CI) runnerJob(rs *runState, j domain.CIJob, token, ghToken string, settings ports.JobSettings) *ports.RunnerJob {241+func (c *CI) runnerJob(rs *runState, j domain.CIJob, sec jobSecrets, settings ports.JobSettings) *ports.RunnerJob {
242 if settings.Vars != nil {242 if settings.Vars != nil {
243 j.ContextsJSON = withVars(j.ContextsJSON, settings.Vars)243 j.ContextsJSON = withVars(j.ContextsJSON, settings.Vars)
244 }244 }
@@ -248,13 +248,24 @@ func (c *CI) runnerJob(rs *runState, j domain.CIJob, token, ghToken string, sett
248 // id in that file.248 // id in that file.
249 path, yaml, j.Key = call.Call.Path, call.Call.YAML, j.CallID249 path, yaml, j.Key = call.Call.Path, call.Call.YAML, j.CallID
250 }250 }
251- return &ports.RunnerJob{251+ out := &ports.RunnerJob{
252- Job: j, Token: token, GithubToken: ghToken, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref,252+ Job: j, Token: sec.token, GithubToken: sec.ghToken, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref,
253 WorkflowPath: path, WorkflowYAML: yaml,253 WorkflowPath: path, WorkflowYAML: yaml,
254 Environment: j.Environment, Vars: settings.Vars, Secrets: settings.Secrets, Trusted: rs.run.Trusted,254 Environment: j.Environment, Vars: settings.Vars, Secrets: settings.Secrets, Trusted: rs.run.Trusted,
255 Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted),255 Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted),
256 Cache: c.cacheScope(rs.run, rs.src.GithubJSON),256 Cache: c.cacheScope(rs.run, rs.src.GithubJSON),
257 }257 }
258+ if sec.idToken != "" && c.cfg.OIDC != nil {
259+ out.IDTokenURL, out.IDTokenRequestToken = c.cfg.OIDC.TokenURL(), sec.idToken
260+ }
261+ return out
262+}
263+
264+// jobSecrets are the secrets issued with an assignment: the job token, the
265+// repository token and the ID token request token ("" when the job gets
266+// none).
267+type jobSecrets struct {
268+ token, ghToken, idToken string
258 }269 }
259 270
260 // assignTo gives the oldest queued job that matches the labels to the runner,271 // assignTo gives the oldest queued job that matches the labels to the runner,
@@ -283,6 +294,7 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str
283 }294 }
284 token := newToken(domain.JobTokenPrefix)295 token := newToken(domain.JobTokenPrefix)
285 ghToken := rs.issueRepoToken(j)296 ghToken := rs.issueRepoToken(j)
297+ idToken := rs.issueIDTokenRequest(j)
286 j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now()298 j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now()
287 j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{}299 j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{}
288 j.RunnerID, j.RunnerName, j.Isolation = runner.ID, runner.Name, runner.Isolation300 j.RunnerID, j.RunnerName, j.Isolation = runner.ID, runner.Name, runner.Isolation
@@ -290,10 +302,10 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str
290 if err := rs.flush(ctx); err != nil {302 if err := rs.flush(ctx); err != nil {
291 return err303 return err
292 }304 }
293- out = c.runnerJob(rs, *j, token, ghToken, settings)305+ out = c.runnerJob(rs, *j, jobSecrets{token: token, ghToken: ghToken, idToken: idToken}, settings)
294 if pollID != "" {306 if pollID != "" {
295 c.mu.Lock()307 c.mu.Lock()
296- c.polls[runner.ID] = pollRecord{pollID: pollID, ref: jobRef(*j), token: token, ghToken: ghToken, hash: j.TokenHash}308+ c.polls[runner.ID] = pollRecord{pollID: pollID, ref: jobRef(*j), token: token, ghToken: ghToken, idToken: idToken, hash: j.TokenHash}
297 c.mu.Unlock()309 c.mu.Unlock()
298 }310 }
299 return nil311 return nil
M internal/usecase/export_test.go +35 -0
@@ -4,6 +4,9 @@ package usecase
44
55 import (
66 "context"
7+ "crypto/rsa"
8+ "slices"
9+ "time"
710
811 "bnema.dev/ermite-actions/expr"
912 "bnema.dev/ermite-actions/template"
@@ -95,3 +98,35 @@ func FilePatch(f domain.FileDiff) (string, bool) { return filePatch(f) }
9598 func PullTipsTrusted(ctx context.Context, cfg CIConfig, repo domain.RepoName, ev ports.PullRequestEvent) (bool, domain.ObjectID) {
9699 return (&CI{cfg: cfg}).pullTipsTrusted(ctx, repo, ev)
97100 }
101+
102+// Thumbprint exposes thumbprint to the tests.
103+func Thumbprint(pub *rsa.PublicKey) string { return thumbprint(pub) }
104+
105+// IDTokenClaims gives the claims idTokenClaims builds, decoded.
106+func IDTokenClaims(contextsJSON, environment, issuer, audience string, at time.Time) (map[string]any, []string, error) {
107+ claims, err := idTokenClaims(contextsJSON, environment, idTokenMeta{issuer: issuer, audience: audience, jti: "jti", now: at})
108+ if err != nil {
109+ return nil, nil, err
110+ }
111+ out := make(map[string]any, len(claims))
112+ keys := make([]string, 0, len(claims))
113+ for _, p := range claims {
114+ out[p.k] = p.v
115+ keys = append(keys, p.k)
116+ }
117+ return out, keys, nil
118+}
119+
120+// SignForTest signs claims (in the order of their keys) with the signing key.
121+func (o *OIDC) SignForTest(claims map[string]any) (string, error) {
122+ keys := make([]string, 0, len(claims))
123+ for k := range claims {
124+ keys = append(keys, k)
125+ }
126+ slices.Sort(keys)
127+ out := make(ordered, 0, len(keys))
128+ for _, k := range keys {
129+ out = append(out, pair{k, claims[k]})
130+ }
131+ return o.sign(out)
132+}
@@ -4,6 +4,9 @@ package usecase
4 4
5 import (5 import (
6 "context"6 "context"
7+ "crypto/rsa"
8+ "slices"
9+ "time"
7 10
8 "bnema.dev/ermite-actions/expr"11 "bnema.dev/ermite-actions/expr"
9 "bnema.dev/ermite-actions/template"12 "bnema.dev/ermite-actions/template"
@@ -95,3 +98,35 @@ func FilePatch(f domain.FileDiff) (string, bool) { return filePatch(f) }
95 func PullTipsTrusted(ctx context.Context, cfg CIConfig, repo domain.RepoName, ev ports.PullRequestEvent) (bool, domain.ObjectID) {98 func PullTipsTrusted(ctx context.Context, cfg CIConfig, repo domain.RepoName, ev ports.PullRequestEvent) (bool, domain.ObjectID) {
96 return (&CI{cfg: cfg}).pullTipsTrusted(ctx, repo, ev)99 return (&CI{cfg: cfg}).pullTipsTrusted(ctx, repo, ev)
97 }100 }
101+
102+// Thumbprint exposes thumbprint to the tests.
103+func Thumbprint(pub *rsa.PublicKey) string { return thumbprint(pub) }
104+
105+// IDTokenClaims gives the claims idTokenClaims builds, decoded.
106+func IDTokenClaims(contextsJSON, environment, issuer, audience string, at time.Time) (map[string]any, []string, error) {
107+ claims, err := idTokenClaims(contextsJSON, environment, idTokenMeta{issuer: issuer, audience: audience, jti: "jti", now: at})
108+ if err != nil {
109+ return nil, nil, err
110+ }
111+ out := make(map[string]any, len(claims))
112+ keys := make([]string, 0, len(claims))
113+ for _, p := range claims {
114+ out[p.k] = p.v
115+ keys = append(keys, p.k)
116+ }
117+ return out, keys, nil
118+}
119+
120+// SignForTest signs claims (in the order of their keys) with the signing key.
121+func (o *OIDC) SignForTest(claims map[string]any) (string, error) {
122+ keys := make([]string, 0, len(claims))
123+ for k := range claims {
124+ keys = append(keys, k)
125+ }
126+ slices.Sort(keys)
127+ out := make(ordered, 0, len(keys))
128+ for _, k := range keys {
129+ out = append(out, pair{k, claims[k]})
130+ }
131+ return o.sign(out)
132+}
A internal/usecase/oidc.go +305 -0
@@ -0,0 +1,305 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "crypto"
8+ "crypto/rand"
9+ "crypto/rsa"
10+ "crypto/sha256"
11+ "crypto/x509"
12+ "encoding/base64"
13+ "encoding/json"
14+ "errors"
15+ "fmt"
16+ "math/big"
17+ "sync"
18+ "time"
19+
20+ "github.com/bnema/zerowrap"
21+
22+ "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/ports"
24+)
25+
26+// The OIDC provider of CI jobs (CI-4, D5). The server signs GitHub-shaped ID
27+// tokens (RS256) with a key of its own, published with the discovery document
28+// and the JWKS under <public URL>/-/oidc, the issuer. The key is generated at
29+// the first start, stored sealed with ERMITE_SECRETS_KEY (without a secrets key
30+// there is no OIDC at all) and replaced by RotateKey; a replaced key stays in
31+// the JWKS for domain.OIDCKeyRetention so that the tokens it signed verify
32+// until they expire.
33+
34+// OIDCPath is where the provider is served under the public URL; the issuer
35+// is the public URL followed by it.
36+const OIDCPath = "/-/oidc"
37+
38+// OIDCKeyBits is the size of the RSA signing keys.
39+const OIDCKeyBits = 3072
40+
41+// OIDCConfig holds what the OIDC provider needs. Store, Tokens, Clock and
42+// PublicURL are required; Sealer nil disables OIDC.
43+type OIDCConfig struct {
44+ Store ports.OIDCKeyStore
45+ // Sealer seals the private keys (ERMITE_SECRETS_KEY). Leave it unset
46+ // rather than assigning a typed nil pointer.
47+ Sealer ports.SecretSealer
48+ // Tokens finds the request tokens of the jobs.
49+ Tokens ports.CIRepoTokenStore
50+ Clock ports.Clock
51+ PublicURL string
52+ // NewKey generates a signing key; nil generates an RSA key of OIDCKeyBits.
53+ // Tests give prepared keys.
54+ NewKey func() (*rsa.PrivateKey, error)
55+}
56+
57+// OIDC is the key management of the provider and the signature of the tokens.
58+// It is safe for concurrent use.
59+type OIDC struct {
60+ cfg OIDCConfig
61+ issuer string
62+
63+ mu sync.RWMutex
64+ // keys are the published keys, oldest first; the last one that is not
65+ // retired signs.
66+ keys []oidcKey
67+}
68+
69+type oidcKey struct {
70+ stored domain.OIDCKey
71+ priv *rsa.PrivateKey
72+}
73+
74+var _ ports.OIDCKeyService = (*OIDC)(nil)
75+
76+// NewOIDC loads the signing keys, generates the first one when there is none,
77+// and forgets the retired keys whose publication is over. It returns nil
78+// without error when cfg.Sealer is nil: the server issues no ID tokens. A
79+// stored key that does not open with the secrets key is an error: the server
80+// must not start (the secrets key check already refuses another key, so this
81+// is a damaged record).
82+func NewOIDC(ctx context.Context, cfg OIDCConfig) (*OIDC, error) {
83+ log := zerowrap.FromCtx(ctx)
84+ if cfg.Sealer == nil {
85+ log.Info().Msg("ERMITE_SECRETS_KEY is not set: CI jobs get no OIDC ID tokens")
86+ return nil, nil
87+ }
88+ if cfg.NewKey == nil {
89+ cfg.NewKey = func() (*rsa.PrivateKey, error) { return rsa.GenerateKey(rand.Reader, OIDCKeyBits) }
90+ }
91+ o := &OIDC{cfg: cfg, issuer: cfg.PublicURL + OIDCPath}
92+ stored, err := cfg.Store.ListOIDCKeys(ctx)
93+ if err != nil {
94+ return nil, err
95+ }
96+ now := cfg.Clock.Now().UTC()
97+ var drop []string
98+ for _, k := range stored {
99+ if !k.Published(now) {
100+ drop = append(drop, k.ID)
101+ continue
102+ }
103+ priv, err := o.open(k)
104+ if err != nil {
105+ return nil, err
106+ }
107+ o.keys = append(o.keys, oidcKey{stored: k, priv: priv})
108+ }
109+ var put []domain.OIDCKey
110+ if _, ok := o.signing(); !ok {
111+ k, err := o.newKey(now)
112+ if err != nil {
113+ return nil, err
114+ }
115+ o.keys = append(o.keys, k)
116+ put = append(put, k.stored)
117+ }
118+ if len(put) > 0 || len(drop) > 0 {
119+ if err := cfg.Store.SaveOIDCKeys(ctx, put, drop); err != nil {
120+ return nil, err
121+ }
122+ }
123+ k, _ := o.signing()
124+ log.Info().Str("issuer", o.issuer).Str("kid", k.stored.ID).Msg("oidc provider ready")
125+ return o, nil
126+}
127+
128+// Issuer is the iss of the tokens.
129+func (o *OIDC) Issuer() string { return o.issuer }
130+
131+// TokenURL is the token endpoint as a job gets it (Job.id_token_url): it has a
132+// query already, so clients append "&audience=".
133+func (o *OIDC) TokenURL() string { return o.issuer + "/token?api-version=2.0" }
134+
135+// open unseals and parses a stored key, and checks that it is the key its id
136+// names.
137+func (o *OIDC) open(k domain.OIDCKey) (*rsa.PrivateKey, error) {
138+ der, err := o.cfg.Sealer.Open(k.Sealed, []byte(domain.OIDCKeyPath(k.ID)))
139+ if err != nil {
140+ return nil, fmt.Errorf("open the oidc key %s: %w", k.ID, err)
141+ }
142+ parsed, err := x509.ParsePKCS8PrivateKey(der)
143+ if err != nil {
144+ return nil, fmt.Errorf("open the oidc key %s: %w", k.ID, err)
145+ }
146+ priv, ok := parsed.(*rsa.PrivateKey)
147+ if !ok || thumbprint(&priv.PublicKey) != k.ID {
148+ return nil, fmt.Errorf("open the oidc key %s: not the key of that id", k.ID)
149+ }
150+ return priv, nil
151+}
152+
153+// newKey generates a key and seals it under its path.
154+func (o *OIDC) newKey(now time.Time) (oidcKey, error) {
155+ priv, err := o.cfg.NewKey()
156+ if err != nil {
157+ return oidcKey{}, fmt.Errorf("generate the oidc key: %w", err)
158+ }
159+ der, err := x509.MarshalPKCS8PrivateKey(priv)
160+ if err != nil {
161+ return oidcKey{}, fmt.Errorf("generate the oidc key: %w", err)
162+ }
163+ kid := thumbprint(&priv.PublicKey)
164+ sealed := o.cfg.Sealer.Seal(der, []byte(domain.OIDCKeyPath(kid)))
165+ clear(der)
166+ return oidcKey{stored: domain.OIDCKey{ID: kid, Sealed: sealed, CreatedAt: now}, priv: priv}, nil
167+}
168+
169+// signing returns the key that signs: the newest that is not retired.
170+func (o *OIDC) signing() (oidcKey, bool) {
171+ for i := len(o.keys) - 1; i >= 0; i-- {
172+ if o.keys[i].stored.RetiredAt.IsZero() {
173+ return o.keys[i], true
174+ }
175+ }
176+ return oidcKey{}, false
177+}
178+
179+// RotateKey replaces the signing key by a new one. The old one signs no more
180+// and stays in the JWKS for domain.OIDCKeyRetention. Retired keys whose
181+// publication is over are deleted with it. Only the owner may rotate.
182+func (o *OIDC) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) {
183+ switch {
184+ case p.IsAnonymous():
185+ return ports.OIDCRotation{}, domain.ErrUnauthenticated
186+ case !p.HasOwnerRights():
187+ return ports.OIDCRotation{}, fmt.Errorf("%w: rotating the oidc key needs the admin token or a token with every scope, no repository list and no branch prefixes", domain.ErrForbidden)
188+ }
189+ o.mu.Lock()
190+ defer o.mu.Unlock()
191+ now := o.cfg.Clock.Now().UTC()
192+ fresh, err := o.newKey(now)
193+ if err != nil {
194+ return ports.OIDCRotation{}, err
195+ }
196+ out := ports.OIDCRotation{Issuer: o.issuer, Kid: fresh.stored.ID}
197+ put := []domain.OIDCKey{fresh.stored}
198+ var drop []string
199+ keys := make([]oidcKey, 0, len(o.keys)+1)
200+ for _, k := range o.keys {
201+ if k.stored.RetiredAt.IsZero() {
202+ k.stored.RetiredAt = now
203+ put = append(put, k.stored)
204+ out.RetiredKid, out.RetiredUntil = k.stored.ID, now.Add(domain.OIDCKeyRetention)
205+ }
206+ if !k.stored.Published(now) {
207+ drop = append(drop, k.stored.ID)
208+ continue
209+ }
210+ keys = append(keys, k)
211+ }
212+ if err := o.cfg.Store.SaveOIDCKeys(ctx, put, drop); err != nil {
213+ return ports.OIDCRotation{}, err
214+ }
215+ o.keys = append(keys, fresh)
216+ log := zerowrap.FromCtx(ctx)
217+ log.Info().Str("kid", fresh.stored.ID).Str("retired_kid", out.RetiredKid).Str("by", p.Label()).Msg("oidc key rotated")
218+ return out, nil
219+}
220+
221+// jwk is a public key of the JWKS (RFC 7517).
222+type jwk struct {
223+ Kty string `json:"kty"`
224+ Alg string `json:"alg"`
225+ Use string `json:"use"`
226+ Kid string `json:"kid"`
227+ N string `json:"n"`
228+ E string `json:"e"`
229+}
230+
231+func publicJWK(kid string, pub *rsa.PublicKey) jwk {
232+ return jwk{Kty: "RSA", Alg: "RS256", Use: "sig", Kid: kid, N: b64(pub.N.Bytes()), E: b64(big.NewInt(int64(pub.E)).Bytes())}
233+}
234+
235+// JWKS returns the JSON of the published keys.
236+func (o *OIDC) JWKS() ([]byte, error) {
237+ now := o.cfg.Clock.Now().UTC()
238+ o.mu.RLock()
239+ defer o.mu.RUnlock()
240+ keys := make([]jwk, 0, len(o.keys))
241+ for _, k := range o.keys {
242+ if k.stored.Published(now) {
243+ keys = append(keys, publicJWK(k.stored.ID, &k.priv.PublicKey))
244+ }
245+ }
246+ return json.Marshal(map[string][]jwk{"keys": keys})
247+}
248+
249+// oidcClaims are the claims a job's token may hold, as listed by the discovery
250+// document (GitHub's, without those of features ermite lacks).
251+var oidcClaims = []string{
252+ "sub", "aud", "exp", "iat", "iss", "jti", "nbf", "ref", "sha", "repository", "repository_id", "repository_owner",
253+ "repository_owner_id", "run_id", "run_number", "run_attempt", "actor", "actor_id", "workflow", "workflow_ref",
254+ "workflow_sha", "head_ref", "base_ref", "event_name", "ref_type", "ref_protected", "environment",
255+ "job_workflow_ref", "job_workflow_sha", "repository_visibility", "runner_environment", "check_run_id",
256+}
257+
258+// Discovery returns the JSON of the OpenID Provider configuration, in the
259+// shape of GitHub's.
260+func (o *OIDC) Discovery() ([]byte, error) {
261+ return json.Marshal(ordered{
262+ {"issuer", o.issuer},
263+ {"jwks_uri", o.issuer + "/.well-known/jwks"},
264+ {"subject_types_supported", []string{"public", "pairwise"}},
265+ {"response_types_supported", []string{"id_token"}},
266+ {"claims_supported", oidcClaims},
267+ {"id_token_signing_alg_values_supported", []string{"RS256"}},
268+ {"scopes_supported", []string{"openid"}},
269+ })
270+}
271+
272+// sign returns the compact JWS of claims, signed RS256 by the signing key.
273+func (o *OIDC) sign(claims ordered) (string, error) {
274+ o.mu.RLock()
275+ k, ok := o.signing()
276+ o.mu.RUnlock()
277+ if !ok {
278+ return "", errors.New("no oidc signing key")
279+ }
280+ header, err := json.Marshal(ordered{{"typ", "JWT"}, {"alg", "RS256"}, {"kid", k.stored.ID}})
281+ if err != nil {
282+ return "", err
283+ }
284+ payload, err := json.Marshal(claims)
285+ if err != nil {
286+ return "", err
287+ }
288+ input := b64(header) + "." + b64(payload)
289+ sum := sha256.Sum256([]byte(input))
290+ sig, err := rsa.SignPKCS1v15(rand.Reader, k.priv, crypto.SHA256, sum[:])
291+ if err != nil {
292+ return "", fmt.Errorf("sign the id token: %w", err)
293+ }
294+ return input + "." + b64(sig), nil
295+}
296+
297+// thumbprint is the RFC 7638 thumbprint of an RSA public key: base64url of the
298+// SHA-256 of {"e":…,"kty":"RSA","n":…} (members in lexical order, no spaces).
299+func thumbprint(pub *rsa.PublicKey) string {
300+ e := b64(big.NewInt(int64(pub.E)).Bytes())
301+ sum := sha256.Sum256([]byte(`{"e":"` + e + `","kty":"RSA","n":"` + b64(pub.N.Bytes()) + `"}`))
302+ return b64(sum[:])
303+}
304+
305+func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
@@ -0,0 +1,305 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "crypto"
8+ "crypto/rand"
9+ "crypto/rsa"
10+ "crypto/sha256"
11+ "crypto/x509"
12+ "encoding/base64"
13+ "encoding/json"
14+ "errors"
15+ "fmt"
16+ "math/big"
17+ "sync"
18+ "time"
19+
20+ "github.com/bnema/zerowrap"
21+
22+ "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/ports"
24+)
25+
26+// The OIDC provider of CI jobs (CI-4, D5). The server signs GitHub-shaped ID
27+// tokens (RS256) with a key of its own, published with the discovery document
28+// and the JWKS under <public URL>/-/oidc, the issuer. The key is generated at
29+// the first start, stored sealed with ERMITE_SECRETS_KEY (without a secrets key
30+// there is no OIDC at all) and replaced by RotateKey; a replaced key stays in
31+// the JWKS for domain.OIDCKeyRetention so that the tokens it signed verify
32+// until they expire.
33+
34+// OIDCPath is where the provider is served under the public URL; the issuer
35+// is the public URL followed by it.
36+const OIDCPath = "/-/oidc"
37+
38+// OIDCKeyBits is the size of the RSA signing keys.
39+const OIDCKeyBits = 3072
40+
41+// OIDCConfig holds what the OIDC provider needs. Store, Tokens, Clock and
42+// PublicURL are required; Sealer nil disables OIDC.
43+type OIDCConfig struct {
44+ Store ports.OIDCKeyStore
45+ // Sealer seals the private keys (ERMITE_SECRETS_KEY). Leave it unset
46+ // rather than assigning a typed nil pointer.
47+ Sealer ports.SecretSealer
48+ // Tokens finds the request tokens of the jobs.
49+ Tokens ports.CIRepoTokenStore
50+ Clock ports.Clock
51+ PublicURL string
52+ // NewKey generates a signing key; nil generates an RSA key of OIDCKeyBits.
53+ // Tests give prepared keys.
54+ NewKey func() (*rsa.PrivateKey, error)
55+}
56+
57+// OIDC is the key management of the provider and the signature of the tokens.
58+// It is safe for concurrent use.
59+type OIDC struct {
60+ cfg OIDCConfig
61+ issuer string
62+
63+ mu sync.RWMutex
64+ // keys are the published keys, oldest first; the last one that is not
65+ // retired signs.
66+ keys []oidcKey
67+}
68+
69+type oidcKey struct {
70+ stored domain.OIDCKey
71+ priv *rsa.PrivateKey
72+}
73+
74+var _ ports.OIDCKeyService = (*OIDC)(nil)
75+
76+// NewOIDC loads the signing keys, generates the first one when there is none,
77+// and forgets the retired keys whose publication is over. It returns nil
78+// without error when cfg.Sealer is nil: the server issues no ID tokens. A
79+// stored key that does not open with the secrets key is an error: the server
80+// must not start (the secrets key check already refuses another key, so this
81+// is a damaged record).
82+func NewOIDC(ctx context.Context, cfg OIDCConfig) (*OIDC, error) {
83+ log := zerowrap.FromCtx(ctx)
84+ if cfg.Sealer == nil {
85+ log.Info().Msg("ERMITE_SECRETS_KEY is not set: CI jobs get no OIDC ID tokens")
86+ return nil, nil
87+ }
88+ if cfg.NewKey == nil {
89+ cfg.NewKey = func() (*rsa.PrivateKey, error) { return rsa.GenerateKey(rand.Reader, OIDCKeyBits) }
90+ }
91+ o := &OIDC{cfg: cfg, issuer: cfg.PublicURL + OIDCPath}
92+ stored, err := cfg.Store.ListOIDCKeys(ctx)
93+ if err != nil {
94+ return nil, err
95+ }
96+ now := cfg.Clock.Now().UTC()
97+ var drop []string
98+ for _, k := range stored {
99+ if !k.Published(now) {
100+ drop = append(drop, k.ID)
101+ continue
102+ }
103+ priv, err := o.open(k)
104+ if err != nil {
105+ return nil, err
106+ }
107+ o.keys = append(o.keys, oidcKey{stored: k, priv: priv})
108+ }
109+ var put []domain.OIDCKey
110+ if _, ok := o.signing(); !ok {
111+ k, err := o.newKey(now)
112+ if err != nil {
113+ return nil, err
114+ }
115+ o.keys = append(o.keys, k)
116+ put = append(put, k.stored)
117+ }
118+ if len(put) > 0 || len(drop) > 0 {
119+ if err := cfg.Store.SaveOIDCKeys(ctx, put, drop); err != nil {
120+ return nil, err
121+ }
122+ }
123+ k, _ := o.signing()
124+ log.Info().Str("issuer", o.issuer).Str("kid", k.stored.ID).Msg("oidc provider ready")
125+ return o, nil
126+}
127+
128+// Issuer is the iss of the tokens.
129+func (o *OIDC) Issuer() string { return o.issuer }
130+
131+// TokenURL is the token endpoint as a job gets it (Job.id_token_url): it has a
132+// query already, so clients append "&audience=".
133+func (o *OIDC) TokenURL() string { return o.issuer + "/token?api-version=2.0" }
134+
135+// open unseals and parses a stored key, and checks that it is the key its id
136+// names.
137+func (o *OIDC) open(k domain.OIDCKey) (*rsa.PrivateKey, error) {
138+ der, err := o.cfg.Sealer.Open(k.Sealed, []byte(domain.OIDCKeyPath(k.ID)))
139+ if err != nil {
140+ return nil, fmt.Errorf("open the oidc key %s: %w", k.ID, err)
141+ }
142+ parsed, err := x509.ParsePKCS8PrivateKey(der)
143+ if err != nil {
144+ return nil, fmt.Errorf("open the oidc key %s: %w", k.ID, err)
145+ }
146+ priv, ok := parsed.(*rsa.PrivateKey)
147+ if !ok || thumbprint(&priv.PublicKey) != k.ID {
148+ return nil, fmt.Errorf("open the oidc key %s: not the key of that id", k.ID)
149+ }
150+ return priv, nil
151+}
152+
153+// newKey generates a key and seals it under its path.
154+func (o *OIDC) newKey(now time.Time) (oidcKey, error) {
155+ priv, err := o.cfg.NewKey()
156+ if err != nil {
157+ return oidcKey{}, fmt.Errorf("generate the oidc key: %w", err)
158+ }
159+ der, err := x509.MarshalPKCS8PrivateKey(priv)
160+ if err != nil {
161+ return oidcKey{}, fmt.Errorf("generate the oidc key: %w", err)
162+ }
163+ kid := thumbprint(&priv.PublicKey)
164+ sealed := o.cfg.Sealer.Seal(der, []byte(domain.OIDCKeyPath(kid)))
165+ clear(der)
166+ return oidcKey{stored: domain.OIDCKey{ID: kid, Sealed: sealed, CreatedAt: now}, priv: priv}, nil
167+}
168+
169+// signing returns the key that signs: the newest that is not retired.
170+func (o *OIDC) signing() (oidcKey, bool) {
171+ for i := len(o.keys) - 1; i >= 0; i-- {
172+ if o.keys[i].stored.RetiredAt.IsZero() {
173+ return o.keys[i], true
174+ }
175+ }
176+ return oidcKey{}, false
177+}
178+
179+// RotateKey replaces the signing key by a new one. The old one signs no more
180+// and stays in the JWKS for domain.OIDCKeyRetention. Retired keys whose
181+// publication is over are deleted with it. Only the owner may rotate.
182+func (o *OIDC) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) {
183+ switch {
184+ case p.IsAnonymous():
185+ return ports.OIDCRotation{}, domain.ErrUnauthenticated
186+ case !p.HasOwnerRights():
187+ return ports.OIDCRotation{}, fmt.Errorf("%w: rotating the oidc key needs the admin token or a token with every scope, no repository list and no branch prefixes", domain.ErrForbidden)
188+ }
189+ o.mu.Lock()
190+ defer o.mu.Unlock()
191+ now := o.cfg.Clock.Now().UTC()
192+ fresh, err := o.newKey(now)
193+ if err != nil {
194+ return ports.OIDCRotation{}, err
195+ }
196+ out := ports.OIDCRotation{Issuer: o.issuer, Kid: fresh.stored.ID}
197+ put := []domain.OIDCKey{fresh.stored}
198+ var drop []string
199+ keys := make([]oidcKey, 0, len(o.keys)+1)
200+ for _, k := range o.keys {
201+ if k.stored.RetiredAt.IsZero() {
202+ k.stored.RetiredAt = now
203+ put = append(put, k.stored)
204+ out.RetiredKid, out.RetiredUntil = k.stored.ID, now.Add(domain.OIDCKeyRetention)
205+ }
206+ if !k.stored.Published(now) {
207+ drop = append(drop, k.stored.ID)
208+ continue
209+ }
210+ keys = append(keys, k)
211+ }
212+ if err := o.cfg.Store.SaveOIDCKeys(ctx, put, drop); err != nil {
213+ return ports.OIDCRotation{}, err
214+ }
215+ o.keys = append(keys, fresh)
216+ log := zerowrap.FromCtx(ctx)
217+ log.Info().Str("kid", fresh.stored.ID).Str("retired_kid", out.RetiredKid).Str("by", p.Label()).Msg("oidc key rotated")
218+ return out, nil
219+}
220+
221+// jwk is a public key of the JWKS (RFC 7517).
222+type jwk struct {
223+ Kty string `json:"kty"`
224+ Alg string `json:"alg"`
225+ Use string `json:"use"`
226+ Kid string `json:"kid"`
227+ N string `json:"n"`
228+ E string `json:"e"`
229+}
230+
231+func publicJWK(kid string, pub *rsa.PublicKey) jwk {
232+ return jwk{Kty: "RSA", Alg: "RS256", Use: "sig", Kid: kid, N: b64(pub.N.Bytes()), E: b64(big.NewInt(int64(pub.E)).Bytes())}
233+}
234+
235+// JWKS returns the JSON of the published keys.
236+func (o *OIDC) JWKS() ([]byte, error) {
237+ now := o.cfg.Clock.Now().UTC()
238+ o.mu.RLock()
239+ defer o.mu.RUnlock()
240+ keys := make([]jwk, 0, len(o.keys))
241+ for _, k := range o.keys {
242+ if k.stored.Published(now) {
243+ keys = append(keys, publicJWK(k.stored.ID, &k.priv.PublicKey))
244+ }
245+ }
246+ return json.Marshal(map[string][]jwk{"keys": keys})
247+}
248+
249+// oidcClaims are the claims a job's token may hold, as listed by the discovery
250+// document (GitHub's, without those of features ermite lacks).
251+var oidcClaims = []string{
252+ "sub", "aud", "exp", "iat", "iss", "jti", "nbf", "ref", "sha", "repository", "repository_id", "repository_owner",
253+ "repository_owner_id", "run_id", "run_number", "run_attempt", "actor", "actor_id", "workflow", "workflow_ref",
254+ "workflow_sha", "head_ref", "base_ref", "event_name", "ref_type", "ref_protected", "environment",
255+ "job_workflow_ref", "job_workflow_sha", "repository_visibility", "runner_environment", "check_run_id",
256+}
257+
258+// Discovery returns the JSON of the OpenID Provider configuration, in the
259+// shape of GitHub's.
260+func (o *OIDC) Discovery() ([]byte, error) {
261+ return json.Marshal(ordered{
262+ {"issuer", o.issuer},
263+ {"jwks_uri", o.issuer + "/.well-known/jwks"},
264+ {"subject_types_supported", []string{"public", "pairwise"}},
265+ {"response_types_supported", []string{"id_token"}},
266+ {"claims_supported", oidcClaims},
267+ {"id_token_signing_alg_values_supported", []string{"RS256"}},
268+ {"scopes_supported", []string{"openid"}},
269+ })
270+}
271+
272+// sign returns the compact JWS of claims, signed RS256 by the signing key.
273+func (o *OIDC) sign(claims ordered) (string, error) {
274+ o.mu.RLock()
275+ k, ok := o.signing()
276+ o.mu.RUnlock()
277+ if !ok {
278+ return "", errors.New("no oidc signing key")
279+ }
280+ header, err := json.Marshal(ordered{{"typ", "JWT"}, {"alg", "RS256"}, {"kid", k.stored.ID}})
281+ if err != nil {
282+ return "", err
283+ }
284+ payload, err := json.Marshal(claims)
285+ if err != nil {
286+ return "", err
287+ }
288+ input := b64(header) + "." + b64(payload)
289+ sum := sha256.Sum256([]byte(input))
290+ sig, err := rsa.SignPKCS1v15(rand.Reader, k.priv, crypto.SHA256, sum[:])
291+ if err != nil {
292+ return "", fmt.Errorf("sign the id token: %w", err)
293+ }
294+ return input + "." + b64(sig), nil
295+}
296+
297+// thumbprint is the RFC 7638 thumbprint of an RSA public key: base64url of the
298+// SHA-256 of {"e":…,"kty":"RSA","n":…} (members in lexical order, no spaces).
299+func thumbprint(pub *rsa.PublicKey) string {
300+ e := b64(big.NewInt(int64(pub.E)).Bytes())
301+ sum := sha256.Sum256([]byte(`{"e":"` + e + `","kty":"RSA","n":"` + b64(pub.N.Bytes()) + `"}`))
302+ return b64(sum[:])
303+}
304+
305+func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
A internal/usecase/oidc_test.go +340 -0
@@ -0,0 +1,340 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto"
9+ "crypto/rand"
10+ "crypto/rsa"
11+ "crypto/sha256"
12+ "encoding/base64"
13+ "encoding/json"
14+ "math/big"
15+ "strings"
16+ "sync"
17+ "testing"
18+ "time"
19+
20+ "github.com/stretchr/testify/assert"
21+ "github.com/stretchr/testify/require"
22+
23+ "bnema.dev/ermite/internal/adapters/kvstore"
24+ "bnema.dev/ermite/internal/adapters/secretbox"
25+ "bnema.dev/ermite/internal/domain"
26+ "bnema.dev/ermite/internal/usecase"
27+)
28+
29+// testRSAKeys are generated once for the package: a key of the real size takes
30+// a while, more so under the race detector.
31+var testRSAKeys struct {
32+ sync.Mutex
33+ keys []*rsa.PrivateKey
34+}
35+
36+// testRSAKey returns the i-th test key.
37+func testRSAKey(t *testing.T, i int) *rsa.PrivateKey {
38+ t.Helper()
39+ testRSAKeys.Lock()
40+ defer testRSAKeys.Unlock()
41+ for len(testRSAKeys.keys) <= i {
42+ k, err := rsa.GenerateKey(rand.Reader, usecase.OIDCKeyBits)
43+ require.NoError(t, err)
44+ testRSAKeys.keys = append(testRSAKeys.keys, k)
45+ }
46+ return testRSAKeys.keys[i]
47+}
48+
49+// keySeq hands out the test keys in order, one per call.
50+func keySeq(t *testing.T) func() (*rsa.PrivateKey, error) {
51+ n := 0
52+ return func() (*rsa.PrivateKey, error) {
53+ k := testRSAKey(t, n)
54+ n++
55+ return k, nil
56+ }
57+}
58+
59+type oidcFixture struct {
60+ store *kvstore.Store
61+ box *secretbox.Box
62+ clock *fakeClock
63+ newKey func() (*rsa.PrivateKey, error)
64+}
65+
66+func newOIDCFixture(t *testing.T) *oidcFixture {
67+ t.Helper()
68+ store, err := kvstore.Open(context.Background(), t.TempDir())
69+ require.NoError(t, err)
70+ t.Cleanup(func() { _ = store.Close(context.Background()) })
71+ box, err := secretbox.New(bytes.Repeat([]byte{7}, secretbox.KeyLen))
72+ require.NoError(t, err)
73+ return &oidcFixture{store: store, box: box, clock: &fakeClock{t: now}, newKey: keySeq(t)}
74+}
75+
76+func (f *oidcFixture) open(t *testing.T) *usecase.OIDC {
77+ t.Helper()
78+ o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{
79+ Store: f.store, Sealer: f.box, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test", NewKey: f.newKey,
80+ })
81+ require.NoError(t, err)
82+ require.NotNil(t, o)
83+ return o
84+}
85+
86+// jwksKids lists the kids of a JWKS, and checks each is the RFC 7638
87+// thumbprint of its key.
88+func jwksKids(t *testing.T, jwks []byte) []string {
89+ t.Helper()
90+ var set struct {
91+ Keys []struct{ Kty, Alg, Use, Kid, N, E string } `json:"keys"`
92+ }
93+ require.NoError(t, json.Unmarshal(jwks, &set))
94+ var kids []string
95+ for _, k := range set.Keys {
96+ assert.Equal(t, "RSA", k.Kty)
97+ assert.Equal(t, "RS256", k.Alg)
98+ assert.Equal(t, "sig", k.Use)
99+ assert.Equal(t, k.Kid, usecase.Thumbprint(jwkPublic(t, k.N, k.E)))
100+ kids = append(kids, k.Kid)
101+ }
102+ return kids
103+}
104+
105+func jwkPublic(t *testing.T, n, e string) *rsa.PublicKey {
106+ t.Helper()
107+ nb, err := base64.RawURLEncoding.DecodeString(n)
108+ require.NoError(t, err)
109+ eb, err := base64.RawURLEncoding.DecodeString(e)
110+ require.NoError(t, err)
111+ return &rsa.PublicKey{N: new(big.Int).SetBytes(nb), E: int(new(big.Int).SetBytes(eb).Int64())}
112+}
113+
114+// The example of RFC 7638, section 3.1.
115+func TestThumbprintRFC7638(t *testing.T) {
116+ t.Parallel()
117+ pub := jwkPublic(t, "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", "AQAB")
118+ assert.Equal(t, "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs", usecase.Thumbprint(pub))
119+}
120+
121+func TestNoSecretsKeyNoOIDC(t *testing.T) {
122+ t.Parallel()
123+ f := newOIDCFixture(t)
124+ o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{Store: f.store, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test"})
125+ require.NoError(t, err)
126+ assert.Nil(t, o)
127+ keys, err := f.store.ListOIDCKeys(context.Background())
128+ require.NoError(t, err)
129+ assert.Empty(t, keys, "no key is generated")
130+}
131+
132+// The key is generated once, stored sealed (never as a PEM or DER in the
133+// clear) and bound to its path, and survives a restart.
134+func TestOIDCKeyIsSealedAndKept(t *testing.T) {
135+ t.Parallel()
136+ ctx := context.Background()
137+ f := newOIDCFixture(t)
138+ o := f.open(t)
139+ assert.Equal(t, "https://forge.test/-/oidc", o.Issuer())
140+ assert.Equal(t, "https://forge.test/-/oidc/token?api-version=2.0", o.TokenURL())
141+ jwks, err := o.JWKS()
142+ require.NoError(t, err)
143+ kids := jwksKids(t, jwks)
144+ require.Len(t, kids, 1)
145+ assert.Equal(t, usecase.Thumbprint(&testRSAKey(t, 0).PublicKey), kids[0])
146+
147+ raw, ok := f.store.RawOIDCKey(ctx, kids[0])
148+ require.True(t, ok)
149+ assert.NotContains(t, string(raw), "PRIVATE KEY", "not a PEM")
150+ priv := testRSAKey(t, 0)
151+ assert.False(t, bytes.Contains(raw, priv.D.Bytes()), "the private exponent is not in the clear")
152+ assert.False(t, bytes.Contains(raw, priv.Primes[0].Bytes()), "nor a prime")
153+
154+ // It survives a restart: no second key.
155+ o2 := f.open(t)
156+ jwks2, err := o2.JWKS()
157+ require.NoError(t, err)
158+ assert.Equal(t, kids, jwksKids(t, jwks2))
159+
160+ // A sealed key moved to another id does not open: the server refuses to
161+ // start rather than serve a key it cannot check.
162+ stored, err := f.store.ListOIDCKeys(ctx)
163+ require.NoError(t, err)
164+ require.Len(t, stored, 1)
165+ moved := stored[0]
166+ moved.ID = "another"
167+ require.NoError(t, f.store.SaveOIDCKeys(ctx, []domain.OIDCKey{moved}, []string{stored[0].ID}))
168+ _, err = usecase.NewOIDC(ctx, usecase.OIDCConfig{Store: f.store, Sealer: f.box, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test", NewKey: f.newKey})
169+ require.Error(t, err)
170+}
171+
172+// Rotation: the new key signs at once, the old one stays published 24 hours
173+// and is forgotten afterwards. Only the owner rotates.
174+func TestOIDCRotation(t *testing.T) {
175+ t.Parallel()
176+ ctx := context.Background()
177+ f := newOIDCFixture(t)
178+ o := f.open(t)
179+ first := usecase.Thumbprint(&testRSAKey(t, 0).PublicKey)
180+ second := usecase.Thumbprint(&testRSAKey(t, 1).PublicKey)
181+
182+ _, err := o.RotateKey(ctx, domain.Anonymous())
183+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
184+ limited := domain.TokenPrincipal(domain.Token{ID: "t", Scopes: domain.AllScopes(), Repos: []domain.RepoName{name(t, "proj")}})
185+ _, err = o.RotateKey(ctx, limited)
186+ require.ErrorIs(t, err, domain.ErrForbidden)
187+
188+ r, err := o.RotateKey(ctx, domain.Root())
189+ require.NoError(t, err)
190+ assert.Equal(t, second, r.Kid)
191+ assert.Equal(t, first, r.RetiredKid)
192+ assert.Equal(t, now.Add(24*time.Hour), r.RetiredUntil)
193+ jwks, err := o.JWKS()
194+ require.NoError(t, err)
195+ assert.ElementsMatch(t, []string{first, second}, jwksKids(t, jwks))
196+
197+ // A token signed now names the new key.
198+ token := signWith(t, o)
199+ assert.Equal(t, second, jwtHeader(t, token)["kid"])
200+
201+ // A restart within the 24 hours still publishes both, the new one signs.
202+ f.clock.t = now.Add(23 * time.Hour)
203+ o = f.open(t)
204+ jwks, err = o.JWKS()
205+ require.NoError(t, err)
206+ assert.ElementsMatch(t, []string{first, second}, jwksKids(t, jwks))
207+
208+ // After 24 hours the old one is no longer published, and a restart
209+ // deletes it.
210+ f.clock.t = now.Add(24 * time.Hour)
211+ jwks, err = o.JWKS()
212+ require.NoError(t, err)
213+ assert.Equal(t, []string{second}, jwksKids(t, jwks))
214+ f.open(t)
215+ stored, err := f.store.ListOIDCKeys(ctx)
216+ require.NoError(t, err)
217+ require.Len(t, stored, 1)
218+ assert.Equal(t, second, stored[0].ID)
219+}
220+
221+// signWith signs a token through the exported path of the tests.
222+func signWith(t *testing.T, o *usecase.OIDC) string {
223+ t.Helper()
224+ tok, err := o.SignForTest(map[string]any{"sub": "x"})
225+ require.NoError(t, err)
226+ verifyJWT(t, tok, o)
227+ return tok
228+}
229+
230+func jwtHeader(t *testing.T, token string) map[string]any {
231+ t.Helper()
232+ h, _, _ := strings.Cut(token, ".")
233+ raw, err := base64.RawURLEncoding.DecodeString(h)
234+ require.NoError(t, err)
235+ var out map[string]any
236+ require.NoError(t, json.Unmarshal(raw, &out))
237+ return out
238+}
239+
240+// verifyJWT checks the RS256 signature of token against the JWKS of o, with
241+// the standard library only, and returns its claims.
242+func verifyJWT(t *testing.T, token string, o *usecase.OIDC) map[string]any {
243+ t.Helper()
244+ jwks, err := o.JWKS()
245+ require.NoError(t, err)
246+ var set struct {
247+ Keys []struct{ Kid, N, E string } `json:"keys"`
248+ }
249+ require.NoError(t, json.Unmarshal(jwks, &set))
250+ parts := strings.Split(token, ".")
251+ require.Len(t, parts, 3)
252+ h := jwtHeader(t, token)
253+ assert.Equal(t, "RS256", h["alg"])
254+ var pub *rsa.PublicKey
255+ for _, k := range set.Keys {
256+ if k.Kid == h["kid"] {
257+ pub = jwkPublic(t, k.N, k.E)
258+ }
259+ }
260+ require.NotNil(t, pub, "the kid is in the JWKS")
261+ sig, err := base64.RawURLEncoding.DecodeString(parts[2])
262+ require.NoError(t, err)
263+ sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
264+ require.NoError(t, rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig))
265+ raw, err := base64.RawURLEncoding.DecodeString(parts[1])
266+ require.NoError(t, err)
267+ var claims map[string]any
268+ require.NoError(t, json.Unmarshal(raw, &claims))
269+ return claims
270+}
271+
272+// The contexts of a job (Job.contexts), as the planner computes them.
273+func jobContexts(event, ref, headRef, baseRef, jobWorkflowRef string) string {
274+ b, _ := json.Marshal(map[string]any{
275+ "github": map[string]any{
276+ "ref": ref, "sha": "abc123", "repository": "owner/proj", "repository_owner": "owner",
277+ "repository_owner_id": "1048577", "run_id": "42", "run_number": "7", "run_attempt": "2",
278+ "repository_visibility": "private", "repository_id": "1048999", "actor_id": "1048577", "actor": "owner",
279+ "workflow": "CI", "head_ref": headRef, "base_ref": baseRef, "event_name": event, "ref_protected": false,
280+ "ref_type": "branch", "workflow_ref": "owner/proj/.github/workflows/ci.yml@" + ref, "workflow_sha": "abc123",
281+ },
282+ "job": map[string]any{
283+ "check_run_id": 9007199254740991, "workflow_ref": jobWorkflowRef, "workflow_sha": "def456",
284+ },
285+ })
286+ return string(b)
287+}
288+
289+// The claims follow GitHub's token: sub per event and environment, the job's
290+// workflow in job_workflow_ref, numbers as strings, the times around iat.
291+func TestIDTokenClaimsPerEvent(t *testing.T) {
292+ t.Parallel()
293+ own := "owner/proj/.github/workflows/ci.yml@refs/heads/main"
294+ called := "owner/tools/.github/workflows/deploy.yml@refs/tags/v1"
295+ tests := []struct {
296+ name, event, ref, head, base, env, jobWF string
297+ sub string
298+ }{
299+ {"push", "push", "refs/heads/main", "", "", "", own, "repo:owner/proj:ref:refs/heads/main"},
300+ {"tag", "push", "refs/tags/v1.0.0", "", "", "", own, "repo:owner/proj:ref:refs/tags/v1.0.0"},
301+ {"dispatch", "workflow_dispatch", "refs/heads/main", "", "", "", own, "repo:owner/proj:ref:refs/heads/main"},
302+ {"schedule", "schedule", "refs/heads/main", "", "", "", own, "repo:owner/proj:ref:refs/heads/main"},
303+ {"pull_request", "pull_request", "refs/pull/3/merge", "feat", "main", "", own, "repo:owner/proj:pull_request"},
304+ {"pull_request_target", "pull_request_target", "refs/heads/main", "feat", "main", "", own, "repo:owner/proj:pull_request"},
305+ {"environment wins", "pull_request", "refs/pull/3/merge", "feat", "main", "prod", own, "repo:owner/proj:environment:prod"},
306+ {"push to an environment", "push", "refs/heads/main", "", "", "staging", own, "repo:owner/proj:environment:staging"},
307+ {"reusable workflow", "push", "refs/heads/main", "", "", "", called, "repo:owner/proj:ref:refs/heads/main"},
308+ }
309+ for _, tt := range tests {
310+ t.Run(tt.name, func(t *testing.T) {
311+ t.Parallel()
312+ c, keys, err := usecase.IDTokenClaims(jobContexts(tt.event, tt.ref, tt.head, tt.base, tt.jobWF), tt.env, "https://forge.test/-/oidc", "https://forge.test/owner", now)
313+ require.NoError(t, err)
314+ assert.Equal(t, tt.sub, c["sub"])
315+ assert.Equal(t, "jti", keys[0])
316+ assert.Equal(t, "https://forge.test/-/oidc", c["iss"])
317+ assert.Equal(t, "https://forge.test/owner", c["aud"])
318+ assert.Equal(t, tt.ref, c["ref"])
319+ assert.Equal(t, tt.event, c["event_name"])
320+ assert.Equal(t, tt.head, c["head_ref"])
321+ assert.Equal(t, tt.base, c["base_ref"])
322+ assert.Equal(t, "2", c["run_attempt"])
323+ assert.Equal(t, "42", c["run_id"])
324+ assert.Equal(t, "false", c["ref_protected"])
325+ assert.Equal(t, "owner/proj/.github/workflows/ci.yml@"+tt.ref, c["workflow_ref"], "the caller's workflow")
326+ assert.Equal(t, tt.jobWF, c["job_workflow_ref"], "the workflow that defines the job")
327+ assert.Equal(t, "def456", c["job_workflow_sha"])
328+ assert.Equal(t, "9007199254740991", c["check_run_id"], "exact, as a string")
329+ assert.Equal(t, "self-hosted", c["runner_environment"])
330+ assert.Equal(t, now.Unix(), c["iat"])
331+ assert.Equal(t, now.Unix()-600, c["nbf"])
332+ assert.Equal(t, now.Unix()+300, c["exp"])
333+ if tt.env != "" {
334+ assert.Equal(t, tt.env, c["environment"])
335+ } else {
336+ assert.NotContains(t, c, "environment", "GitHub leaves it out without an environment")
337+ }
338+ })
339+ }
340+}
@@ -0,0 +1,340 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto"
9+ "crypto/rand"
10+ "crypto/rsa"
11+ "crypto/sha256"
12+ "encoding/base64"
13+ "encoding/json"
14+ "math/big"
15+ "strings"
16+ "sync"
17+ "testing"
18+ "time"
19+
20+ "github.com/stretchr/testify/assert"
21+ "github.com/stretchr/testify/require"
22+
23+ "bnema.dev/ermite/internal/adapters/kvstore"
24+ "bnema.dev/ermite/internal/adapters/secretbox"
25+ "bnema.dev/ermite/internal/domain"
26+ "bnema.dev/ermite/internal/usecase"
27+)
28+
29+// testRSAKeys are generated once for the package: a key of the real size takes
30+// a while, more so under the race detector.
31+var testRSAKeys struct {
32+ sync.Mutex
33+ keys []*rsa.PrivateKey
34+}
35+
36+// testRSAKey returns the i-th test key.
37+func testRSAKey(t *testing.T, i int) *rsa.PrivateKey {
38+ t.Helper()
39+ testRSAKeys.Lock()
40+ defer testRSAKeys.Unlock()
41+ for len(testRSAKeys.keys) <= i {
42+ k, err := rsa.GenerateKey(rand.Reader, usecase.OIDCKeyBits)
43+ require.NoError(t, err)
44+ testRSAKeys.keys = append(testRSAKeys.keys, k)
45+ }
46+ return testRSAKeys.keys[i]
47+}
48+
49+// keySeq hands out the test keys in order, one per call.
50+func keySeq(t *testing.T) func() (*rsa.PrivateKey, error) {
51+ n := 0
52+ return func() (*rsa.PrivateKey, error) {
53+ k := testRSAKey(t, n)
54+ n++
55+ return k, nil
56+ }
57+}
58+
59+type oidcFixture struct {
60+ store *kvstore.Store
61+ box *secretbox.Box
62+ clock *fakeClock
63+ newKey func() (*rsa.PrivateKey, error)
64+}
65+
66+func newOIDCFixture(t *testing.T) *oidcFixture {
67+ t.Helper()
68+ store, err := kvstore.Open(context.Background(), t.TempDir())
69+ require.NoError(t, err)
70+ t.Cleanup(func() { _ = store.Close(context.Background()) })
71+ box, err := secretbox.New(bytes.Repeat([]byte{7}, secretbox.KeyLen))
72+ require.NoError(t, err)
73+ return &oidcFixture{store: store, box: box, clock: &fakeClock{t: now}, newKey: keySeq(t)}
74+}
75+
76+func (f *oidcFixture) open(t *testing.T) *usecase.OIDC {
77+ t.Helper()
78+ o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{
79+ Store: f.store, Sealer: f.box, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test", NewKey: f.newKey,
80+ })
81+ require.NoError(t, err)
82+ require.NotNil(t, o)
83+ return o
84+}
85+
86+// jwksKids lists the kids of a JWKS, and checks each is the RFC 7638
87+// thumbprint of its key.
88+func jwksKids(t *testing.T, jwks []byte) []string {
89+ t.Helper()
90+ var set struct {
91+ Keys []struct{ Kty, Alg, Use, Kid, N, E string } `json:"keys"`
92+ }
93+ require.NoError(t, json.Unmarshal(jwks, &set))
94+ var kids []string
95+ for _, k := range set.Keys {
96+ assert.Equal(t, "RSA", k.Kty)
97+ assert.Equal(t, "RS256", k.Alg)
98+ assert.Equal(t, "sig", k.Use)
99+ assert.Equal(t, k.Kid, usecase.Thumbprint(jwkPublic(t, k.N, k.E)))
100+ kids = append(kids, k.Kid)
101+ }
102+ return kids
103+}
104+
105+func jwkPublic(t *testing.T, n, e string) *rsa.PublicKey {
106+ t.Helper()
107+ nb, err := base64.RawURLEncoding.DecodeString(n)
108+ require.NoError(t, err)
109+ eb, err := base64.RawURLEncoding.DecodeString(e)
110+ require.NoError(t, err)
111+ return &rsa.PublicKey{N: new(big.Int).SetBytes(nb), E: int(new(big.Int).SetBytes(eb).Int64())}
112+}
113+
114+// The example of RFC 7638, section 3.1.
115+func TestThumbprintRFC7638(t *testing.T) {
116+ t.Parallel()
117+ pub := jwkPublic(t, "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", "AQAB")
118+ assert.Equal(t, "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs", usecase.Thumbprint(pub))
119+}
120+
121+func TestNoSecretsKeyNoOIDC(t *testing.T) {
122+ t.Parallel()
123+ f := newOIDCFixture(t)
124+ o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{Store: f.store, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test"})
125+ require.NoError(t, err)
126+ assert.Nil(t, o)
127+ keys, err := f.store.ListOIDCKeys(context.Background())
128+ require.NoError(t, err)
129+ assert.Empty(t, keys, "no key is generated")
130+}
131+
132+// The key is generated once, stored sealed (never as a PEM or DER in the
133+// clear) and bound to its path, and survives a restart.
134+func TestOIDCKeyIsSealedAndKept(t *testing.T) {
135+ t.Parallel()
136+ ctx := context.Background()
137+ f := newOIDCFixture(t)
138+ o := f.open(t)
139+ assert.Equal(t, "https://forge.test/-/oidc", o.Issuer())
140+ assert.Equal(t, "https://forge.test/-/oidc/token?api-version=2.0", o.TokenURL())
141+ jwks, err := o.JWKS()
142+ require.NoError(t, err)
143+ kids := jwksKids(t, jwks)
144+ require.Len(t, kids, 1)
145+ assert.Equal(t, usecase.Thumbprint(&testRSAKey(t, 0).PublicKey), kids[0])
146+
147+ raw, ok := f.store.RawOIDCKey(ctx, kids[0])
148+ require.True(t, ok)
149+ assert.NotContains(t, string(raw), "PRIVATE KEY", "not a PEM")
150+ priv := testRSAKey(t, 0)
151+ assert.False(t, bytes.Contains(raw, priv.D.Bytes()), "the private exponent is not in the clear")
152+ assert.False(t, bytes.Contains(raw, priv.Primes[0].Bytes()), "nor a prime")
153+
154+ // It survives a restart: no second key.
155+ o2 := f.open(t)
156+ jwks2, err := o2.JWKS()
157+ require.NoError(t, err)
158+ assert.Equal(t, kids, jwksKids(t, jwks2))
159+
160+ // A sealed key moved to another id does not open: the server refuses to
161+ // start rather than serve a key it cannot check.
162+ stored, err := f.store.ListOIDCKeys(ctx)
163+ require.NoError(t, err)
164+ require.Len(t, stored, 1)
165+ moved := stored[0]
166+ moved.ID = "another"
167+ require.NoError(t, f.store.SaveOIDCKeys(ctx, []domain.OIDCKey{moved}, []string{stored[0].ID}))
168+ _, err = usecase.NewOIDC(ctx, usecase.OIDCConfig{Store: f.store, Sealer: f.box, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test", NewKey: f.newKey})
169+ require.Error(t, err)
170+}
171+
172+// Rotation: the new key signs at once, the old one stays published 24 hours
173+// and is forgotten afterwards. Only the owner rotates.
174+func TestOIDCRotation(t *testing.T) {
175+ t.Parallel()
176+ ctx := context.Background()
177+ f := newOIDCFixture(t)
178+ o := f.open(t)
179+ first := usecase.Thumbprint(&testRSAKey(t, 0).PublicKey)
180+ second := usecase.Thumbprint(&testRSAKey(t, 1).PublicKey)
181+
182+ _, err := o.RotateKey(ctx, domain.Anonymous())
183+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
184+ limited := domain.TokenPrincipal(domain.Token{ID: "t", Scopes: domain.AllScopes(), Repos: []domain.RepoName{name(t, "proj")}})
185+ _, err = o.RotateKey(ctx, limited)
186+ require.ErrorIs(t, err, domain.ErrForbidden)
187+
188+ r, err := o.RotateKey(ctx, domain.Root())
189+ require.NoError(t, err)
190+ assert.Equal(t, second, r.Kid)
191+ assert.Equal(t, first, r.RetiredKid)
192+ assert.Equal(t, now.Add(24*time.Hour), r.RetiredUntil)
193+ jwks, err := o.JWKS()
194+ require.NoError(t, err)
195+ assert.ElementsMatch(t, []string{first, second}, jwksKids(t, jwks))
196+
197+ // A token signed now names the new key.
198+ token := signWith(t, o)
199+ assert.Equal(t, second, jwtHeader(t, token)["kid"])
200+
201+ // A restart within the 24 hours still publishes both, the new one signs.
202+ f.clock.t = now.Add(23 * time.Hour)
203+ o = f.open(t)
204+ jwks, err = o.JWKS()
205+ require.NoError(t, err)
206+ assert.ElementsMatch(t, []string{first, second}, jwksKids(t, jwks))
207+
208+ // After 24 hours the old one is no longer published, and a restart
209+ // deletes it.
210+ f.clock.t = now.Add(24 * time.Hour)
211+ jwks, err = o.JWKS()
212+ require.NoError(t, err)
213+ assert.Equal(t, []string{second}, jwksKids(t, jwks))
214+ f.open(t)
215+ stored, err := f.store.ListOIDCKeys(ctx)
216+ require.NoError(t, err)
217+ require.Len(t, stored, 1)
218+ assert.Equal(t, second, stored[0].ID)
219+}
220+
221+// signWith signs a token through the exported path of the tests.
222+func signWith(t *testing.T, o *usecase.OIDC) string {
223+ t.Helper()
224+ tok, err := o.SignForTest(map[string]any{"sub": "x"})
225+ require.NoError(t, err)
226+ verifyJWT(t, tok, o)
227+ return tok
228+}
229+
230+func jwtHeader(t *testing.T, token string) map[string]any {
231+ t.Helper()
232+ h, _, _ := strings.Cut(token, ".")
233+ raw, err := base64.RawURLEncoding.DecodeString(h)
234+ require.NoError(t, err)
235+ var out map[string]any
236+ require.NoError(t, json.Unmarshal(raw, &out))
237+ return out
238+}
239+
240+// verifyJWT checks the RS256 signature of token against the JWKS of o, with
241+// the standard library only, and returns its claims.
242+func verifyJWT(t *testing.T, token string, o *usecase.OIDC) map[string]any {
243+ t.Helper()
244+ jwks, err := o.JWKS()
245+ require.NoError(t, err)
246+ var set struct {
247+ Keys []struct{ Kid, N, E string } `json:"keys"`
248+ }
249+ require.NoError(t, json.Unmarshal(jwks, &set))
250+ parts := strings.Split(token, ".")
251+ require.Len(t, parts, 3)
252+ h := jwtHeader(t, token)
253+ assert.Equal(t, "RS256", h["alg"])
254+ var pub *rsa.PublicKey
255+ for _, k := range set.Keys {
256+ if k.Kid == h["kid"] {
257+ pub = jwkPublic(t, k.N, k.E)
258+ }
259+ }
260+ require.NotNil(t, pub, "the kid is in the JWKS")
261+ sig, err := base64.RawURLEncoding.DecodeString(parts[2])
262+ require.NoError(t, err)
263+ sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
264+ require.NoError(t, rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig))
265+ raw, err := base64.RawURLEncoding.DecodeString(parts[1])
266+ require.NoError(t, err)
267+ var claims map[string]any
268+ require.NoError(t, json.Unmarshal(raw, &claims))
269+ return claims
270+}
271+
272+// The contexts of a job (Job.contexts), as the planner computes them.
273+func jobContexts(event, ref, headRef, baseRef, jobWorkflowRef string) string {
274+ b, _ := json.Marshal(map[string]any{
275+ "github": map[string]any{
276+ "ref": ref, "sha": "abc123", "repository": "owner/proj", "repository_owner": "owner",
277+ "repository_owner_id": "1048577", "run_id": "42", "run_number": "7", "run_attempt": "2",
278+ "repository_visibility": "private", "repository_id": "1048999", "actor_id": "1048577", "actor": "owner",
279+ "workflow": "CI", "head_ref": headRef, "base_ref": baseRef, "event_name": event, "ref_protected": false,
280+ "ref_type": "branch", "workflow_ref": "owner/proj/.github/workflows/ci.yml@" + ref, "workflow_sha": "abc123",
281+ },
282+ "job": map[string]any{
283+ "check_run_id": 9007199254740991, "workflow_ref": jobWorkflowRef, "workflow_sha": "def456",
284+ },
285+ })
286+ return string(b)
287+}
288+
289+// The claims follow GitHub's token: sub per event and environment, the job's
290+// workflow in job_workflow_ref, numbers as strings, the times around iat.
291+func TestIDTokenClaimsPerEvent(t *testing.T) {
292+ t.Parallel()
293+ own := "owner/proj/.github/workflows/ci.yml@refs/heads/main"
294+ called := "owner/tools/.github/workflows/deploy.yml@refs/tags/v1"
295+ tests := []struct {
296+ name, event, ref, head, base, env, jobWF string
297+ sub string
298+ }{
299+ {"push", "push", "refs/heads/main", "", "", "", own, "repo:owner/proj:ref:refs/heads/main"},
300+ {"tag", "push", "refs/tags/v1.0.0", "", "", "", own, "repo:owner/proj:ref:refs/tags/v1.0.0"},
301+ {"dispatch", "workflow_dispatch", "refs/heads/main", "", "", "", own, "repo:owner/proj:ref:refs/heads/main"},
302+ {"schedule", "schedule", "refs/heads/main", "", "", "", own, "repo:owner/proj:ref:refs/heads/main"},
303+ {"pull_request", "pull_request", "refs/pull/3/merge", "feat", "main", "", own, "repo:owner/proj:pull_request"},
304+ {"pull_request_target", "pull_request_target", "refs/heads/main", "feat", "main", "", own, "repo:owner/proj:pull_request"},
305+ {"environment wins", "pull_request", "refs/pull/3/merge", "feat", "main", "prod", own, "repo:owner/proj:environment:prod"},
306+ {"push to an environment", "push", "refs/heads/main", "", "", "staging", own, "repo:owner/proj:environment:staging"},
307+ {"reusable workflow", "push", "refs/heads/main", "", "", "", called, "repo:owner/proj:ref:refs/heads/main"},
308+ }
309+ for _, tt := range tests {
310+ t.Run(tt.name, func(t *testing.T) {
311+ t.Parallel()
312+ c, keys, err := usecase.IDTokenClaims(jobContexts(tt.event, tt.ref, tt.head, tt.base, tt.jobWF), tt.env, "https://forge.test/-/oidc", "https://forge.test/owner", now)
313+ require.NoError(t, err)
314+ assert.Equal(t, tt.sub, c["sub"])
315+ assert.Equal(t, "jti", keys[0])
316+ assert.Equal(t, "https://forge.test/-/oidc", c["iss"])
317+ assert.Equal(t, "https://forge.test/owner", c["aud"])
318+ assert.Equal(t, tt.ref, c["ref"])
319+ assert.Equal(t, tt.event, c["event_name"])
320+ assert.Equal(t, tt.head, c["head_ref"])
321+ assert.Equal(t, tt.base, c["base_ref"])
322+ assert.Equal(t, "2", c["run_attempt"])
323+ assert.Equal(t, "42", c["run_id"])
324+ assert.Equal(t, "false", c["ref_protected"])
325+ assert.Equal(t, "owner/proj/.github/workflows/ci.yml@"+tt.ref, c["workflow_ref"], "the caller's workflow")
326+ assert.Equal(t, tt.jobWF, c["job_workflow_ref"], "the workflow that defines the job")
327+ assert.Equal(t, "def456", c["job_workflow_sha"])
328+ assert.Equal(t, "9007199254740991", c["check_run_id"], "exact, as a string")
329+ assert.Equal(t, "self-hosted", c["runner_environment"])
330+ assert.Equal(t, now.Unix(), c["iat"])
331+ assert.Equal(t, now.Unix()-600, c["nbf"])
332+ assert.Equal(t, now.Unix()+300, c["exp"])
333+ if tt.env != "" {
334+ assert.Equal(t, tt.env, c["environment"])
335+ } else {
336+ assert.NotContains(t, c, "environment", "GitHub leaves it out without an environment")
337+ }
338+ })
339+ }
340+}
M internal/usecase/tokens.go +3 -2
@@ -378,7 +378,8 @@ func (a *Auth) authenticateRepoToken(ctx context.Context, h domain.TokenHash) (d
378378 if err != nil {
379379 return domain.Principal{}, fmt.Errorf("find repository token: %w", err)
380380 }
381- if t.Expired(a.clock.Now().UTC()) {
381+ if t.OIDC || t.Expired(a.clock.Now().UTC()) {
382+ // An ID token request token is only for the token endpoint.
382383 return domain.Principal{}, domain.ErrUnauthenticated
383384 }
384385 return domain.RepoTokenPrincipal(t), nil
@@ -397,7 +398,7 @@ func (a *Auth) revalidateRepoToken(ctx context.Context, id string) error {
397398 if err != nil {
398399 return fmt.Errorf("find repository token: %w", err)
399400 }
400- if t.Expired(a.clock.Now().UTC()) {
401+ if t.OIDC || t.Expired(a.clock.Now().UTC()) {
401402 return domain.ErrUnauthenticated
402403 }
403404 return nil
@@ -378,7 +378,8 @@ func (a *Auth) authenticateRepoToken(ctx context.Context, h domain.TokenHash) (d
378 if err != nil {378 if err != nil {
379 return domain.Principal{}, fmt.Errorf("find repository token: %w", err)379 return domain.Principal{}, fmt.Errorf("find repository token: %w", err)
380 }380 }
381- if t.Expired(a.clock.Now().UTC()) {381+ if t.OIDC || t.Expired(a.clock.Now().UTC()) {
382+ // An ID token request token is only for the token endpoint.
382 return domain.Principal{}, domain.ErrUnauthenticated383 return domain.Principal{}, domain.ErrUnauthenticated
383 }384 }
384 return domain.RepoTokenPrincipal(t), nil385 return domain.RepoTokenPrincipal(t), nil
@@ -397,7 +398,7 @@ func (a *Auth) revalidateRepoToken(ctx context.Context, id string) error {
397 if err != nil {398 if err != nil {
398 return fmt.Errorf("find repository token: %w", err)399 return fmt.Errorf("find repository token: %w", err)
399 }400 }
400- if t.Expired(a.clock.Now().UTC()) {401+ if t.OIDC || t.Expired(a.clock.Now().UTC()) {
401 return domain.ErrUnauthenticated402 return domain.ErrUnauthenticated
402 }403 }
403 return nil404 return nil
M proto/ermite/storage/ci.proto +5 -0
@@ -328,6 +328,11 @@ message CIRepoToken {
328328 // Absent: the restricted default, read-only, as tokens issued before the
329329 // field existed (an older binary reads a newer token as read-only too).
330330 map<string, string> permissions = 11;
331+ // Set for the ID token request token of the job ("ermiteoidc_"), which only
332+ // the OIDC token endpoint accepts. A binary that predates the field reads it
333+ // as a job's own token, but never looks it up: it only resolves secrets with
334+ // the "ermitegh_" prefix, whose hashes are not those of these tokens.
335+ bool oidc = 12;
331336 }
332337
333338 // CIActionResolution is the resolution of an action reference for a run
@@ -328,6 +328,11 @@ message CIRepoToken {
328 // Absent: the restricted default, read-only, as tokens issued before the328 // Absent: the restricted default, read-only, as tokens issued before the
329 // field existed (an older binary reads a newer token as read-only too).329 // field existed (an older binary reads a newer token as read-only too).
330 map<string, string> permissions = 11;330 map<string, string> permissions = 11;
331+ // Set for the ID token request token of the job ("ermiteoidc_"), which only
332+ // the OIDC token endpoint accepts. A binary that predates the field reads it
333+ // as a job's own token, but never looks it up: it only resolves secrets with
334+ // the "ermitegh_" prefix, whose hashes are not those of these tokens.
335+ bool oidc = 12;
331 }336 }
332 337
333 // CIActionResolution is the resolution of an action reference for a run338 // CIActionResolution is the resolution of an action reference for a run
A proto/ermite/storage/oidc.proto +24 -0
@@ -0,0 +1,24 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Internal, unversioned storage schema of ermite: the keys that sign the OIDC
4+// ID tokens of CI jobs (CI-4).
5+
6+syntax = "proto3";
7+
8+package ermite.storage;
9+
10+import "google/protobuf/timestamp.proto";
11+
12+option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb";
13+
14+// OIDCKey is stored under "oidc/key/<kid>", kid being the RFC 7638 thumbprint
15+// of its public key. The private key is never stored in the clear: sealed is
16+// its PKCS #8 DER sealed with ERMITE_SECRETS_KEY (AES-256-GCM, see secretbox),
17+// with the key path as additional data.
18+message OIDCKey {
19+ bytes sealed = 1;
20+ google.protobuf.Timestamp created_at = 2;
21+ // Set once the key was replaced: it signs no more, and leaves the JWKS 24
22+ // hours later.
23+ google.protobuf.Timestamp retired_at = 3;
24+}
@@ -0,0 +1,24 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Internal, unversioned storage schema of ermite: the keys that sign the OIDC
4+// ID tokens of CI jobs (CI-4).
5+
6+syntax = "proto3";
7+
8+package ermite.storage;
9+
10+import "google/protobuf/timestamp.proto";
11+
12+option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb";
13+
14+// OIDCKey is stored under "oidc/key/<kid>", kid being the RFC 7638 thumbprint
15+// of its public key. The private key is never stored in the clear: sealed is
16+// its PKCS #8 DER sealed with ERMITE_SECRETS_KEY (AES-256-GCM, see secretbox),
17+// with the key path as additional data.
18+message OIDCKey {
19+ bytes sealed = 1;
20+ google.protobuf.Timestamp created_at = 2;
21+ // Set once the key was replaced: it signs no more, and leaves the JWKS 24
22+ // hours later.
23+ google.protobuf.Timestamp retired_at = 3;
24+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close