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 Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe key is unknown
35 files · +2632 -23
M .hexcheck.yaml +3 -0
| @@ -76,6 +76,9 @@ allow: | |||
| 76 | 76 | - rule: no-adapter-to-adapter-imports | |
| 77 | 77 | path: internal/adapters/githubapi/** | |
| 78 | 78 | 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 | 82 | - rule: no-adapter-to-adapter-imports | |
| 80 | 83 | path: internal/adapters/rpc/** | |
| 81 | 84 | reason: rate-limits API clients with internal/clientlimit | |
| @@ -76,6 +76,9 @@ allow: | |||||
| 76 | - rule: no-adapter-to-adapter-imports | 76 | - 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/clientlimit | 78 | 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-imports | 82 | - rule: no-adapter-to-adapter-imports | ||
| 80 | path: internal/adapters/rpc/** | 83 | path: internal/adapters/rpc/** | ||
| 81 | reason: rate-limits API clients with internal/clientlimit | 84 | 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 | |||
| 121 | 121 | ||
| 122 | 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 | 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 | 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. | |
| @@ -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 generated file: collapsed
A gen/ermite/storage/oidc.pb.go +157 -0
| @@ -0,0 +1,157 @@ | |||
| 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 | + | // Code generated by protoc-gen-go. DO NOT EDIT. | |
| 7 | + | // versions: | |
| 8 | + | // protoc-gen-go v1.36.12 | |
| 9 | + | // protoc (unknown) | |
| 10 | + | // source: ermite/storage/oidc.proto | |
| 11 | + | ||
| 12 | + | package storagepb | |
| 13 | + | ||
| 14 | + | import ( | |
| 15 | + | protoreflect "google.golang.org/protobuf/reflect/protoreflect" | |
| 16 | + | protoimpl "google.golang.org/protobuf/runtime/protoimpl" | |
| 17 | + | timestamppb "google.golang.org/protobuf/types/known/timestamppb" | |
| 18 | + | reflect "reflect" | |
| 19 | + | sync "sync" | |
| 20 | + | unsafe "unsafe" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | const ( | |
| 24 | + | // Verify that this generated code is sufficiently up-to-date. | |
| 25 | + | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) | |
| 26 | + | // Verify that runtime/protoimpl is sufficiently up-to-date. | |
| 27 | + | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) | |
| 28 | + | ) | |
| 29 | + | ||
| 30 | + | // OIDCKey is stored under "oidc/key/<kid>", kid being the RFC 7638 thumbprint | |
| 31 | + | // of its public key. The private key is never stored in the clear: sealed is | |
| 32 | + | // its PKCS #8 DER sealed with ERMITE_SECRETS_KEY (AES-256-GCM, see secretbox), | |
| 33 | + | // with the key path as additional data. | |
| 34 | + | type OIDCKey struct { | |
| 35 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 36 | + | Sealed []byte `protobuf:"bytes,1,opt,name=sealed,proto3" json:"sealed,omitempty"` | |
| 37 | + | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | |
| 38 | + | // Set once the key was replaced: it signs no more, and leaves the JWKS 24 | |
| 39 | + | // hours later. | |
| 40 | + | RetiredAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=retired_at,json=retiredAt,proto3" json:"retired_at,omitempty"` | |
| 41 | + | unknownFields protoimpl.UnknownFields | |
| 42 | + | sizeCache protoimpl.SizeCache | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (x *OIDCKey) Reset() { | |
| 46 | + | *x = OIDCKey{} | |
| 47 | + | mi := &file_ermite_storage_oidc_proto_msgTypes[0] | |
| 48 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 49 | + | ms.StoreMessageInfo(mi) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (x *OIDCKey) String() string { | |
| 53 | + | return protoimpl.X.MessageStringOf(x) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (*OIDCKey) ProtoMessage() {} | |
| 57 | + | ||
| 58 | + | func (x *OIDCKey) ProtoReflect() protoreflect.Message { | |
| 59 | + | mi := &file_ermite_storage_oidc_proto_msgTypes[0] | |
| 60 | + | if x != nil { | |
| 61 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 62 | + | if ms.LoadMessageInfo() == nil { | |
| 63 | + | ms.StoreMessageInfo(mi) | |
| 64 | + | } | |
| 65 | + | return ms | |
| 66 | + | } | |
| 67 | + | return mi.MessageOf(x) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // Deprecated: Use OIDCKey.ProtoReflect.Descriptor instead. | |
| 71 | + | func (*OIDCKey) Descriptor() ([]byte, []int) { | |
| 72 | + | return file_ermite_storage_oidc_proto_rawDescGZIP(), []int{0} | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (x *OIDCKey) GetSealed() []byte { | |
| 76 | + | if x != nil { | |
| 77 | + | return x.Sealed | |
| 78 | + | } | |
| 79 | + | return nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (x *OIDCKey) GetCreatedAt() *timestamppb.Timestamp { | |
| 83 | + | if x != nil { | |
| 84 | + | return x.CreatedAt | |
| 85 | + | } | |
| 86 | + | return nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (x *OIDCKey) GetRetiredAt() *timestamppb.Timestamp { | |
| 90 | + | if x != nil { | |
| 91 | + | return x.RetiredAt | |
| 92 | + | } | |
| 93 | + | return nil | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | var File_ermite_storage_oidc_proto protoreflect.FileDescriptor | |
| 97 | + | ||
| 98 | + | const file_ermite_storage_oidc_proto_rawDesc = "" + | |
| 99 | + | "\n" + | |
| 100 | + | "\x19ermite/storage/oidc.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\x97\x01\n" + | |
| 101 | + | "\aOIDCKey\x12\x16\n" + | |
| 102 | + | "\x06sealed\x18\x01 \x01(\fR\x06sealed\x129\n" + | |
| 103 | + | "\n" + | |
| 104 | + | "created_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + | |
| 105 | + | "\n" + | |
| 106 | + | "retired_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\tretiredAtB/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3" | |
| 107 | + | ||
| 108 | + | var ( | |
| 109 | + | file_ermite_storage_oidc_proto_rawDescOnce sync.Once | |
| 110 | + | file_ermite_storage_oidc_proto_rawDescData []byte | |
| 111 | + | ) | |
| 112 | + | ||
| 113 | + | func file_ermite_storage_oidc_proto_rawDescGZIP() []byte { | |
| 114 | + | file_ermite_storage_oidc_proto_rawDescOnce.Do(func() { | |
| 115 | + | file_ermite_storage_oidc_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ermite_storage_oidc_proto_rawDesc), len(file_ermite_storage_oidc_proto_rawDesc))) | |
| 116 | + | }) | |
| 117 | + | return file_ermite_storage_oidc_proto_rawDescData | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | var file_ermite_storage_oidc_proto_msgTypes = make([]protoimpl.MessageInfo, 1) | |
| 121 | + | var file_ermite_storage_oidc_proto_goTypes = []any{ | |
| 122 | + | (*OIDCKey)(nil), // 0: ermite.storage.OIDCKey | |
| 123 | + | (*timestamppb.Timestamp)(nil), // 1: google.protobuf.Timestamp | |
| 124 | + | } | |
| 125 | + | var file_ermite_storage_oidc_proto_depIdxs = []int32{ | |
| 126 | + | 1, // 0: ermite.storage.OIDCKey.created_at:type_name -> google.protobuf.Timestamp | |
| 127 | + | 1, // 1: ermite.storage.OIDCKey.retired_at:type_name -> google.protobuf.Timestamp | |
| 128 | + | 2, // [2:2] is the sub-list for method output_type | |
| 129 | + | 2, // [2:2] is the sub-list for method input_type | |
| 130 | + | 2, // [2:2] is the sub-list for extension type_name | |
| 131 | + | 2, // [2:2] is the sub-list for extension extendee | |
| 132 | + | 0, // [0:2] is the sub-list for field type_name | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func init() { file_ermite_storage_oidc_proto_init() } | |
| 136 | + | func file_ermite_storage_oidc_proto_init() { | |
| 137 | + | if File_ermite_storage_oidc_proto != nil { | |
| 138 | + | return | |
| 139 | + | } | |
| 140 | + | type x struct{} | |
| 141 | + | out := protoimpl.TypeBuilder{ | |
| 142 | + | File: protoimpl.DescBuilder{ | |
| 143 | + | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | |
| 144 | + | RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_oidc_proto_rawDesc), len(file_ermite_storage_oidc_proto_rawDesc)), | |
| 145 | + | NumEnums: 0, | |
| 146 | + | NumMessages: 1, | |
| 147 | + | NumExtensions: 0, | |
| 148 | + | NumServices: 0, | |
| 149 | + | }, | |
| 150 | + | GoTypes: file_ermite_storage_oidc_proto_goTypes, | |
| 151 | + | DependencyIndexes: file_ermite_storage_oidc_proto_depIdxs, | |
| 152 | + | MessageInfos: file_ermite_storage_oidc_proto_msgTypes, | |
| 153 | + | }.Build() | |
| 154 | + | File_ermite_storage_oidc_proto = out.File | |
| 155 | + | file_ermite_storage_oidc_proto_goTypes = nil | |
| 156 | + | file_ermite_storage_oidc_proto_depIdxs = nil | |
| 157 | + | } | |
| @@ -0,0 +1,157 @@ | |||||
| 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 | + | // Code generated by protoc-gen-go. DO NOT EDIT. | |||
| 7 | + | // versions: | |||
| 8 | + | // protoc-gen-go v1.36.12 | |||
| 9 | + | // protoc (unknown) | |||
| 10 | + | // source: ermite/storage/oidc.proto | |||
| 11 | + | ||||
| 12 | + | package storagepb | |||
| 13 | + | ||||
| 14 | + | import ( | |||
| 15 | + | protoreflect "google.golang.org/protobuf/reflect/protoreflect" | |||
| 16 | + | protoimpl "google.golang.org/protobuf/runtime/protoimpl" | |||
| 17 | + | timestamppb "google.golang.org/protobuf/types/known/timestamppb" | |||
| 18 | + | reflect "reflect" | |||
| 19 | + | sync "sync" | |||
| 20 | + | unsafe "unsafe" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | const ( | |||
| 24 | + | // Verify that this generated code is sufficiently up-to-date. | |||
| 25 | + | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) | |||
| 26 | + | // Verify that runtime/protoimpl is sufficiently up-to-date. | |||
| 27 | + | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) | |||
| 28 | + | ) | |||
| 29 | + | ||||
| 30 | + | // OIDCKey is stored under "oidc/key/<kid>", kid being the RFC 7638 thumbprint | |||
| 31 | + | // of its public key. The private key is never stored in the clear: sealed is | |||
| 32 | + | // its PKCS #8 DER sealed with ERMITE_SECRETS_KEY (AES-256-GCM, see secretbox), | |||
| 33 | + | // with the key path as additional data. | |||
| 34 | + | type OIDCKey struct { | |||
| 35 | + | state protoimpl.MessageState `protogen:"open.v1"` | |||
| 36 | + | Sealed []byte `protobuf:"bytes,1,opt,name=sealed,proto3" json:"sealed,omitempty"` | |||
| 37 | + | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | |||
| 38 | + | // Set once the key was replaced: it signs no more, and leaves the JWKS 24 | |||
| 39 | + | // hours later. | |||
| 40 | + | RetiredAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=retired_at,json=retiredAt,proto3" json:"retired_at,omitempty"` | |||
| 41 | + | unknownFields protoimpl.UnknownFields | |||
| 42 | + | sizeCache protoimpl.SizeCache | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (x *OIDCKey) Reset() { | |||
| 46 | + | *x = OIDCKey{} | |||
| 47 | + | mi := &file_ermite_storage_oidc_proto_msgTypes[0] | |||
| 48 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 49 | + | ms.StoreMessageInfo(mi) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (x *OIDCKey) String() string { | |||
| 53 | + | return protoimpl.X.MessageStringOf(x) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func (*OIDCKey) ProtoMessage() {} | |||
| 57 | + | ||||
| 58 | + | func (x *OIDCKey) ProtoReflect() protoreflect.Message { | |||
| 59 | + | mi := &file_ermite_storage_oidc_proto_msgTypes[0] | |||
| 60 | + | if x != nil { | |||
| 61 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 62 | + | if ms.LoadMessageInfo() == nil { | |||
| 63 | + | ms.StoreMessageInfo(mi) | |||
| 64 | + | } | |||
| 65 | + | return ms | |||
| 66 | + | } | |||
| 67 | + | return mi.MessageOf(x) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Deprecated: Use OIDCKey.ProtoReflect.Descriptor instead. | |||
| 71 | + | func (*OIDCKey) Descriptor() ([]byte, []int) { | |||
| 72 | + | return file_ermite_storage_oidc_proto_rawDescGZIP(), []int{0} | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (x *OIDCKey) GetSealed() []byte { | |||
| 76 | + | if x != nil { | |||
| 77 | + | return x.Sealed | |||
| 78 | + | } | |||
| 79 | + | return nil | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (x *OIDCKey) GetCreatedAt() *timestamppb.Timestamp { | |||
| 83 | + | if x != nil { | |||
| 84 | + | return x.CreatedAt | |||
| 85 | + | } | |||
| 86 | + | return nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (x *OIDCKey) GetRetiredAt() *timestamppb.Timestamp { | |||
| 90 | + | if x != nil { | |||
| 91 | + | return x.RetiredAt | |||
| 92 | + | } | |||
| 93 | + | return nil | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | var File_ermite_storage_oidc_proto protoreflect.FileDescriptor | |||
| 97 | + | ||||
| 98 | + | const file_ermite_storage_oidc_proto_rawDesc = "" + | |||
| 99 | + | "\n" + | |||
| 100 | + | "\x19ermite/storage/oidc.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\x97\x01\n" + | |||
| 101 | + | "\aOIDCKey\x12\x16\n" + | |||
| 102 | + | "\x06sealed\x18\x01 \x01(\fR\x06sealed\x129\n" + | |||
| 103 | + | "\n" + | |||
| 104 | + | "created_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + | |||
| 105 | + | "\n" + | |||
| 106 | + | "retired_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\tretiredAtB/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3" | |||
| 107 | + | ||||
| 108 | + | var ( | |||
| 109 | + | file_ermite_storage_oidc_proto_rawDescOnce sync.Once | |||
| 110 | + | file_ermite_storage_oidc_proto_rawDescData []byte | |||
| 111 | + | ) | |||
| 112 | + | ||||
| 113 | + | func file_ermite_storage_oidc_proto_rawDescGZIP() []byte { | |||
| 114 | + | file_ermite_storage_oidc_proto_rawDescOnce.Do(func() { | |||
| 115 | + | file_ermite_storage_oidc_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ermite_storage_oidc_proto_rawDesc), len(file_ermite_storage_oidc_proto_rawDesc))) | |||
| 116 | + | }) | |||
| 117 | + | return file_ermite_storage_oidc_proto_rawDescData | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | var file_ermite_storage_oidc_proto_msgTypes = make([]protoimpl.MessageInfo, 1) | |||
| 121 | + | var file_ermite_storage_oidc_proto_goTypes = []any{ | |||
| 122 | + | (*OIDCKey)(nil), // 0: ermite.storage.OIDCKey | |||
| 123 | + | (*timestamppb.Timestamp)(nil), // 1: google.protobuf.Timestamp | |||
| 124 | + | } | |||
| 125 | + | var file_ermite_storage_oidc_proto_depIdxs = []int32{ | |||
| 126 | + | 1, // 0: ermite.storage.OIDCKey.created_at:type_name -> google.protobuf.Timestamp | |||
| 127 | + | 1, // 1: ermite.storage.OIDCKey.retired_at:type_name -> google.protobuf.Timestamp | |||
| 128 | + | 2, // [2:2] is the sub-list for method output_type | |||
| 129 | + | 2, // [2:2] is the sub-list for method input_type | |||
| 130 | + | 2, // [2:2] is the sub-list for extension type_name | |||
| 131 | + | 2, // [2:2] is the sub-list for extension extendee | |||
| 132 | + | 0, // [0:2] is the sub-list for field type_name | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func init() { file_ermite_storage_oidc_proto_init() } | |||
| 136 | + | func file_ermite_storage_oidc_proto_init() { | |||
| 137 | + | if File_ermite_storage_oidc_proto != nil { | |||
| 138 | + | return | |||
| 139 | + | } | |||
| 140 | + | type x struct{} | |||
| 141 | + | out := protoimpl.TypeBuilder{ | |||
| 142 | + | File: protoimpl.DescBuilder{ | |||
| 143 | + | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | |||
| 144 | + | RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_oidc_proto_rawDesc), len(file_ermite_storage_oidc_proto_rawDesc)), | |||
| 145 | + | NumEnums: 0, | |||
| 146 | + | NumMessages: 1, | |||
| 147 | + | NumExtensions: 0, | |||
| 148 | + | NumServices: 0, | |||
| 149 | + | }, | |||
| 150 | + | GoTypes: file_ermite_storage_oidc_proto_goTypes, | |||
| 151 | + | DependencyIndexes: file_ermite_storage_oidc_proto_depIdxs, | |||
| 152 | + | MessageInfos: file_ermite_storage_oidc_proto_msgTypes, | |||
| 153 | + | }.Build() | |||
| 154 | + | File_ermite_storage_oidc_proto = out.File | |||
| 155 | + | file_ermite_storage_oidc_proto_goTypes = nil | |||
| 156 | + | file_ermite_storage_oidc_proto_depIdxs = nil | |||
| 157 | + | } | |||
M go.mod +1 -1
| @@ -13,7 +13,7 @@ tool ( | |||
| 13 | 13 | ||
| 14 | 14 | require ( | |
| 15 | 15 | bnema.dev/ermite-actions v0.2.0 | |
| 16 | - | bnema.dev/ermite-api v0.3.0 | |
| 16 | + | bnema.dev/ermite-api v0.4.0 | |
| 17 | 17 | bnema.dev/ermite-git v0.1.0 | |
| 18 | 18 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | |
| 19 | 19 | connectrpc.com/connect v1.21.0 | |
| @@ -13,7 +13,7 @@ tool ( | |||||
| 13 | 13 | ||||
| 14 | require ( | 14 | require ( | ||
| 15 | bnema.dev/ermite-actions v0.2.0 | 15 | bnema.dev/ermite-actions v0.2.0 | ||
| 16 | - | bnema.dev/ermite-api v0.3.0 | 16 | + | bnema.dev/ermite-api v0.4.0 |
| 17 | bnema.dev/ermite-git v0.1.0 | 17 | bnema.dev/ermite-git v0.1.0 | ||
| 18 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | 18 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | ||
| 19 | connectrpc.com/connect v1.21.0 | 19 | 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 { | |||
| 62 | 62 | func encodeRepoToken(t domain.CIRepoToken) []byte { | |
| 63 | 63 | return mustMarshal(&storagepb.CIRepoToken{ | |
| 64 | 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 | 78 | t := domain.CIRepoToken{ | |
| 79 | 79 | ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(), | |
| 80 | 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 | 83 | copy(t.Hash[:], pb.GetHash()) | |
| 84 | 84 | 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, nil | 84 | 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 | |||
| 39 | 39 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |
| 40 | 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 | 46 | // Config configures the API. | |
| 43 | 47 | type Config struct { | |
| 44 | 48 | Auth ports.AuthService | |
| @@ -77,6 +81,10 @@ type Config struct { | |||
| 77 | 81 | Secrets ports.SecretService | |
| 78 | 82 | Variables ports.VariableService | |
| 79 | 83 | 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 | 90 | // API is the set of Connect handlers of the contract. | |
| @@ -111,7 +119,10 @@ func New(cfg Config) *API { | |||
| 111 | 119 | if settings { | |
| 112 | 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 | 126 | mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...)) | |
| 116 | 127 | if cfg.Sessions != nil { | |
| 117 | 128 | 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.AuthService | 48 | Auth ports.AuthService | ||
| @@ -77,6 +81,10 @@ type Config struct { | |||||
| 77 | Secrets ports.SecretService | 81 | Secrets ports.SecretService | ||
| 78 | Variables ports.VariableService | 82 | Variables ports.VariableService | ||
| 79 | Environments ports.EnvironmentService | 83 | 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 { | |||
| 225 | 225 | Secrets: j.Secrets, Vars: j.Vars, Environment: j.Environment, Trusted: j.Trusted, | |
| 226 | 226 | Permissions: &pb.JobPermissions{Levels: j.Permissions}, | |
| 227 | 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 | ||
| @@ -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 | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "errors" | |
| 7 | 8 | "slices" | |
| 8 | 9 | ||
| 9 | 10 | "connectrpc.com/connect" | |
| 10 | 11 | ||
| 11 | 12 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | 14 | "bnema.dev/ermite/internal/ports" | |
| 13 | 15 | ) | |
| 14 | 16 | ||
| @@ -17,6 +19,8 @@ type serverService struct { | |||
| 17 | 19 | version string | |
| 18 | 20 | features []string | |
| 19 | 21 | 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 | 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 | 39 | AllowedSigners: info.AllowedSigners(), | |
| 36 | 40 | }), 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 | + | } | |
| @@ -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 string | 19 | version string | ||
| 18 | features []string | 20 | features []string | ||
| 19 | key ports.SigningKeyService | 21 | 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 | }), nil | 40 | }), 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 | |||
| 101 | 101 | if hookExe == "" { | |
| 102 | 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 | 105 | if tweak != nil { | |
| 106 | 106 | tweak(&opts) | |
| 107 | 107 | } | |
| @@ -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 ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "crypto/ed25519" | |
| 8 | 8 | "crypto/rand" | |
| 9 | + | "crypto/rsa" | |
| 9 | 10 | "errors" | |
| 10 | 11 | "fmt" | |
| 11 | 12 | "net" | |
| @@ -26,6 +27,7 @@ import ( | |||
| 26 | 27 | "bnema.dev/ermite/internal/adapters/gitremote" | |
| 27 | 28 | "bnema.dev/ermite/internal/adapters/httpserver" | |
| 28 | 29 | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 30 | + | "bnema.dev/ermite/internal/adapters/oidchttp" | |
| 29 | 31 | "bnema.dev/ermite/internal/adapters/rpc" | |
| 30 | 32 | "bnema.dev/ermite/internal/adapters/secretbox" | |
| 31 | 33 | "bnema.dev/ermite/internal/adapters/sshsig" | |
| @@ -67,6 +69,9 @@ type options struct { | |||
| 67 | 69 | // ci overrides the settings of the CI engine (tests shorten its | |
| 68 | 70 | // durations). | |
| 69 | 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 | 77 | // remote returns the lister of github.com refs: the test's, else def. | |
| @@ -117,8 +122,11 @@ type forge struct { | |||
| 117 | 122 | CI *usecase.CI | |
| 118 | 123 | // Settings are the secrets, variables and environments of CI. | |
| 119 | 124 | 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 | |
| 122 | 130 | } | |
| 123 | 131 | ||
| 124 | 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 | 250 | if err != nil { | |
| 243 | 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 | 262 | // CI (ADR 0025): the engine reads workflows through the reader, and plans | |
| 247 | 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 | 275 | RefTips: store.CI(), | |
| 260 | 276 | Settings: f.Settings, | |
| 261 | 277 | Workflows: opts.workflows(githubraw.New(githubraw.Config{})), | |
| 278 | + | OIDC: f.OIDC, | |
| 262 | 279 | } | |
| 263 | 280 | f.pullRequests(&ciCfg, store, git, committer) | |
| 264 | 281 | if opts.ci != nil { | |
| @@ -317,6 +334,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 317 | 334 | if f.Passkeys != nil { | |
| 318 | 335 | 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 | 341 | f.API = rpc.New(rpc.Config{ | |
| 321 | 342 | Auth: f.Auth, | |
| 322 | 343 | Repos: f.Repos, | |
| @@ -332,6 +353,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 332 | 353 | Secrets: f.Settings, | |
| 333 | 354 | Variables: f.Settings, | |
| 334 | 355 | Environments: f.Settings, | |
| 356 | + | OIDCKeys: apiOIDC, | |
| 335 | 357 | Version: serverVersion(), | |
| 336 | 358 | TrustedProxies: cfg.TrustedProxies, | |
| 337 | 359 | Limiter: limiter, | |
| @@ -348,7 +370,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 348 | 370 | Limiter: limiter, | |
| 349 | 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 | 374 | for _, p := range githttp.Patterns { | |
| 353 | 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 | 385 | for _, p := range githubapi.Patterns { | |
| 364 | 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 | 393 | warnSessionsNeedHTTPS(ctx, cfg.PublicURL) | |
| 367 | 394 | warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient) | |
| 368 | 395 | 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 its | 69 | // 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.CI | 122 | 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.CISettings | 124 | Settings *usecase.CISettings | ||
| 120 | - | API *rpc.API | 125 | + | // OIDC is the provider of the ID tokens of CI jobs; nil without a secrets |
| 121 | - | Bus ports.EventBus | 126 | + | // 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 plans | 262 | // 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.Passkeys | 335 | 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 { | |||
| 32 | 32 | newServeCmd(), newHookCmd(), | |
| 33 | 33 | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | |
| 34 | 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 | 37 | return root | |
| 38 | 38 | } | |
| @@ -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 root | 37 | 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 { | |||
| 43 | 43 | Archive bool | |
| 44 | 44 | // Sha is the commit an archive token is bound to; empty for the job's own. | |
| 45 | 45 | 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 | 50 | // Permissions are the permissions of the job's own token (key to level, see | |
| 47 | 51 | // TokenPermissions). Empty: the default, which reads the repository. | |
| 48 | 52 | Permissions map[string]string | |
| @@ -74,7 +78,13 @@ const RepoTokenGrace = 5 * time.Minute | |||
| 74 | 78 | // does not show itself to it. | |
| 75 | 79 | // | |
| 76 | 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 | 84 | func RepoTokenPrincipal(t CIRepoToken) Principal { | |
| 85 | + | if t.OIDC { | |
| 86 | + | return Anonymous() | |
| 87 | + | } | |
| 78 | 88 | if t.Archive { | |
| 79 | 89 | return Principal{ | |
| 80 | 90 | kind: kindArchive, tokenID: repoTokenIDPrefix + t.ID, label: "ci archive", expiresAt: t.ExpiresAt, | |
| @@ -43,6 +43,10 @@ type CIRepoToken struct { | |||||
| 43 | Archive bool | 43 | 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 string | 45 | 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, see | 50 | // 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]string | 52 | 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 { | |||
| 433 | 433 | Permissions map[string]string | |
| 434 | 434 | // Cache is the scope of the job in the runner's cache (Job.cache). | |
| 435 | 435 | 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 | 443 | // 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]string | 433 | 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 JobCache | 435 | 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 entries | 443 | // 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 { | |||
| 106 | 106 | // read when an action of another repository is resolved for the run. Nil | |
| 107 | 107 | // reads nothing for a token (fail closed). | |
| 108 | 108 | 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 | 112 | // Settings resolves the variables and secrets of a job (CI-3). Nil gives | |
| 110 | 113 | // jobs none, and an empty "vars" context. Leave it unset rather than | |
| 111 | 114 | // assigning a typed nil pointer. | |
| @@ -267,6 +270,7 @@ type pollRecord struct { | |||
| 267 | 270 | ref string | |
| 268 | 271 | token string | |
| 269 | 272 | ghToken string | |
| 273 | + | idToken string | |
| 270 | 274 | hash domain.TokenHash | |
| 271 | 275 | } | |
| 272 | 276 | ||
| @@ -106,6 +106,9 @@ type CIConfig struct { | |||||
| 106 | // read when an action of another repository is resolved for the run. Nil | 106 | // 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.TokenStore | 108 | 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 gives | 112 | // 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 than | 113 | // 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 string | 270 | ref string | ||
| 268 | token string | 271 | token string | ||
| 269 | ghToken string | 272 | ghToken string | ||
| 273 | + | idToken string | |||
| 270 | hash domain.TokenHash | 274 | 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 | |||
| 321 | 321 | {"job", ""}, {"ref", run.Ref}, {"sha", run.Sha}, {"repository", u.full}, {"repository_owner", c.cfg.Owner}, | |
| 322 | 322 | {"repository_owner_id", strconv.FormatInt(owner.id, 10)}, {"repositoryUrl", "git://" + u.host + "/" + u.full + ".git"}, | |
| 323 | 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 | 325 | {"artifact_cache_size_limit", ghCacheLimit}, {"repository_visibility", string(repo.Visibility)}, | |
| 326 | 326 | {"actor_id", strconv.FormatInt(id.id, 10)}, {"actor", id.login}, {"workflow", run.WorkflowName}, | |
| 327 | 327 | {"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 | |||
| 216 | 216 | if err != nil { | |
| 217 | 217 | 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 | 220 | return nil | |
| 221 | 221 | }) | |
| 222 | 222 | switch { | |
| @@ -238,7 +238,7 @@ func (c *CI) repeatPoll(ctx context.Context, runner domain.CIRunner, pollID stri | |||
| 238 | 238 | // | |
| 239 | 239 | // TODO(ci3-c2): the runner writes the "Set up job" log; it states there that | |
| 240 | 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 | 242 | if settings.Vars != nil { | |
| 243 | 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 | 248 | // id in that file. | |
| 249 | 249 | path, yaml, j.Key = call.Call.Path, call.Call.YAML, j.CallID | |
| 250 | 250 | } | |
| 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, | |
| 253 | 253 | WorkflowPath: path, WorkflowYAML: yaml, | |
| 254 | 254 | Environment: j.Environment, Vars: settings.Vars, Secrets: settings.Secrets, Trusted: rs.run.Trusted, | |
| 255 | 255 | Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted), | |
| 256 | 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 | 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 | 295 | token := newToken(domain.JobTokenPrefix) | |
| 285 | 296 | ghToken := rs.issueRepoToken(j) | |
| 297 | + | idToken := rs.issueIDTokenRequest(j) | |
| 286 | 298 | j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now() | |
| 287 | 299 | j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{} | |
| 288 | 300 | 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 | 302 | if err := rs.flush(ctx); err != nil { | |
| 291 | 303 | 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 | 306 | if pollID != "" { | |
| 295 | 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 | 309 | c.mu.Unlock() | |
| 298 | 310 | } | |
| 299 | 311 | 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 err | 217 | 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 nil | 220 | 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 that | 239 | // 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.CallID | 249 | 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.Isolation | 300 | 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 err | 303 | 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 nil | 311 | return nil | ||
M internal/usecase/export_test.go +35 -0
| @@ -4,6 +4,9 @@ package usecase | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "crypto/rsa" | |
| 8 | + | "slices" | |
| 9 | + | "time" | |
| 7 | 10 | ||
| 8 | 11 | "bnema.dev/ermite-actions/expr" | |
| 9 | 12 | "bnema.dev/ermite-actions/template" | |
| @@ -95,3 +98,35 @@ func FilePatch(f domain.FileDiff) (string, bool) { return filePatch(f) } | |||
| 95 | 98 | func PullTipsTrusted(ctx context.Context, cfg CIConfig, repo domain.RepoName, ev ports.PullRequestEvent) (bool, domain.ObjectID) { | |
| 96 | 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 | + | } | |
| @@ -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 | |||
| 378 | 378 | if err != nil { | |
| 379 | 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 | 383 | return domain.Principal{}, domain.ErrUnauthenticated | |
| 383 | 384 | } | |
| 384 | 385 | return domain.RepoTokenPrincipal(t), nil | |
| @@ -397,7 +398,7 @@ func (a *Auth) revalidateRepoToken(ctx context.Context, id string) error { | |||
| 397 | 398 | if err != nil { | |
| 398 | 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 | 402 | return domain.ErrUnauthenticated | |
| 402 | 403 | } | |
| 403 | 404 | 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.ErrUnauthenticated | 383 | return domain.Principal{}, domain.ErrUnauthenticated | ||
| 383 | } | 384 | } | ||
| 384 | return domain.RepoTokenPrincipal(t), nil | 385 | 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.ErrUnauthenticated | 402 | return domain.ErrUnauthenticated | ||
| 402 | } | 403 | } | ||
| 403 | return nil | 404 | return nil | ||
M proto/ermite/storage/ci.proto +5 -0
| @@ -328,6 +328,11 @@ message CIRepoToken { | |||
| 328 | 328 | // Absent: the restricted default, read-only, as tokens issued before the | |
| 329 | 329 | // field existed (an older binary reads a newer token as read-only too). | |
| 330 | 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 | 338 | // 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 the | 328 | // 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 run | 338 | // 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 | + | } | |||