Skip to content

Commit f1de3ac

Browse files
committed
feat(oidc): add CEL-based OIDC claims processor
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
1 parent 3218241 commit f1de3ac

13 files changed

Lines changed: 2304 additions & 247 deletions

errors/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ var (
178178
ErrReceivedUnexpectedAuthHeader = errors.New("received unexpected www-authenticate header")
179179
ErrNoBearerToken = errors.New("no bearer token given")
180180
ErrInvalidBearerToken = errors.New("invalid bearer token given")
181+
ErrInvalidOrUnreachableOIDCIssuer = errors.New("invalid or unreachable oidc issuer")
181182
ErrInsufficientScope = errors.New("bearer token does not have sufficient scope")
182183
ErrCouldNotLoadPublicKey = errors.New("failed to load public key")
183184
ErrEventTypeEmpty = errors.New("event type empty")

examples/README-OIDC-WORKLOAD-IDENTITY.md

Lines changed: 170 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,16 @@ OIDC Workload Identity authentication allows workloads (e.g., Kubernetes pods, C
1010

1111
- **Secret-less Authentication**: No need to manage static credentials
1212
- **Automatic Credential Rotation**: Tokens are short-lived and automatically rotated
13-
- **Fine-grained Access Control**: Map OIDC claims to Zot identities and groups
13+
- **Fine-grained Access Control**: Map OIDC claims to Zot identities and groups using CEL expressions
1414
- **Kubernetes Native**: Works seamlessly with Kubernetes ServiceAccount tokens
15+
- **Multi-Provider Support**: Configure multiple OIDC issuers for different workload types (e.g. multiple clusters)
1516
- **Standards-based**: Uses standard OIDC protocols
1617

1718
## Configuration
1819

1920
### Basic Configuration
2021

21-
Add OIDC workload identity configuration to your bearer authentication settings:
22+
Add OIDC workload identity configuration to your bearer authentication settings. The `oidc` field accepts an array of provider configurations:
2223

2324
```json
2425
{
@@ -27,10 +28,12 @@ Add OIDC workload identity configuration to your bearer authentication settings:
2728
"bearer": {
2829
"realm": "zot",
2930
"service": "zot-service",
30-
"oidc": {
31-
"issuer": "https://kubernetes.default.svc.cluster.local",
32-
"audiences": ["zot"]
33-
}
31+
"oidc": [
32+
{
33+
"issuer": "https://kubernetes.default.svc.cluster.local",
34+
"audiences": ["zot"]
35+
}
36+
]
3437
}
3538
}
3639
}
@@ -46,16 +49,41 @@ Add OIDC workload identity configuration to your bearer authentication settings:
4649
- **`audiences`** (required): List of acceptable audiences for the OIDC token. At least one must be specified.
4750
- Example: `["zot", "https://zot.example.com"]`
4851

49-
- **`claimMapping.username`** (optional): Which OIDC claim to use as the username. Defaults to `"sub"`.
50-
- Acceptable values: `"sub"`, `"email"`, `"preferred_username"`, `"name"`, or any custom claim
51-
- Example: `"preferred_username"`
52-
53-
- **`jwksDiscoveryUrl`** (optional): Override the JWKS discovery URL. If not provided, it defaults to `{issuer}/.well-known/openid-configuration`.
52+
- **`claimMapping`** (optional): CEL-based configuration for validating and mapping OIDC claims.
53+
- **`variables`**: List of variables to extract from claims using CEL expressions
54+
- **`validations`**: List of validation rules with CEL expressions
55+
- **`username`**: CEL expression to extract the username. Default: `"claims.iss + '/' + claims.sub"`
56+
- **`groups`**: CEL expression to extract groups. Default: none (no groups extracted)
5457

5558
- **`skipIssuerVerification`** (optional): Skip issuer verification (for testing only). Default: `false`.
5659

60+
### CEL Expressions
61+
62+
Zot uses [Common Expression Language (CEL)](https://github.com/google/cel-go) for flexible claim validation and mapping. CEL expressions have access to:
63+
64+
- **`claims`**: The OIDC token claims as a map (e.g., `claims.sub`, `claims.email`)
65+
- **`vars`**: Previously extracted variables (for use in validations and username/groups expressions)
66+
67+
#### Example CEL Expressions
68+
69+
| Expression | Description |
70+
|------------|-------------|
71+
| `claims.sub` | Extract the subject claim |
72+
| `claims.email` | Extract the email claim |
73+
| `claims.groups` | Extract the groups claim |
74+
| `claims['kubernetes.io/serviceaccount/namespace']` | Extract claims with special characters |
75+
| `claims.repository_owner + '/' + claims.sub` | Concatenate multiple claims |
76+
| `claims.email.split('@')[0]` | Extract username from email |
77+
| `claims.org in ['allowed-org-1', 'allowed-org-2']` | Check if org is in allowed list |
78+
| `claims.email_verified == true` | Validate email is verified |
79+
5780
### Complete Example
5881

82+
In the example below, the username is mapped from both the issuer and subject
83+
claims to uniquely identify Kubernetes ServiceAccounts across different clusters.
84+
Note that `claims.iss + '/' + claims.sub` is the default username mapping if none
85+
is specified (so the whole `claimMapping` section could be omitted in this example).
86+
5987
```json
6088
{
6189
"distSpecVersion": "1.1.1",
@@ -69,21 +97,23 @@ Add OIDC workload identity configuration to your bearer authentication settings:
6997
"bearer": {
7098
"realm": "zot",
7199
"service": "zot-service",
72-
"oidc": {
73-
"issuer": "https://kubernetes.default.svc.cluster.local",
74-
"audiences": ["zot", "https://zot.example.com"],
75-
"claimMapping": {
76-
"username": "sub"
100+
"oidc": [
101+
{
102+
"issuer": "https://kubernetes.default.svc.cluster.local",
103+
"audiences": ["zot", "https://zot.example.com"],
104+
"claimMapping": {
105+
"username": "claims.iss + '/' + claims.sub"
106+
}
77107
}
78-
}
108+
]
79109
}
80110
},
81111
"accessControl": {
82112
"repositories": {
83113
"**": {
84114
"policies": [
85115
{
86-
"users": ["system:serviceaccount:default:flux-controller"],
116+
"users": ["https://kubernetes.default.svc.cluster.local/system:serviceaccount:flux-system:source-controller"],
87117
"actions": ["read", "create", "update", "delete"]
88118
}
89119
]
@@ -97,6 +127,94 @@ Add OIDC workload identity configuration to your bearer authentication settings:
97127
}
98128
```
99129

130+
### Advanced Configuration with CEL Validations
131+
132+
Use CEL expressions to validate claims and extract complex usernames:
133+
134+
```json
135+
{
136+
"http": {
137+
"auth": {
138+
"bearer": {
139+
"oidc": [
140+
{
141+
"issuer": "https://token.actions.githubusercontent.com",
142+
"audiences": ["zot"],
143+
"claimMapping": {
144+
"variables": [
145+
{
146+
"name": "repo",
147+
"expression": "claims.repository"
148+
},
149+
{
150+
"name": "owner",
151+
"expression": "claims.repository_owner"
152+
}
153+
],
154+
"validations": [
155+
{
156+
"expression": "vars.owner == 'my-org'",
157+
"message": "only my-org repositories are allowed"
158+
},
159+
{
160+
"expression": "claims.ref.startsWith('refs/heads/')",
161+
"message": "must be a branch reference"
162+
}
163+
],
164+
"username": "vars.repo",
165+
"groups": "['github-actions', 'ci']"
166+
}
167+
}
168+
]
169+
}
170+
}
171+
}
172+
}
173+
```
174+
175+
### Multiple OIDC Providers
176+
177+
Configure multiple OIDC providers to support different identity sources. Zot will try each provider in order until one successfully authenticates the token:
178+
179+
```json
180+
{
181+
"http": {
182+
"auth": {
183+
"bearer": {
184+
"oidc": [
185+
{
186+
"issuer": "https://kubernetes.default.svc.cluster.local",
187+
"audiences": ["zot"],
188+
"claimMapping": {
189+
"variables": [
190+
{
191+
"name": "ns",
192+
"expression": "claims['kubernetes.io/serviceaccount/namespace']"
193+
},
194+
{
195+
"name": "sa",
196+
"expression": "claims['kubernetes.io/serviceaccount/service-account.name']"
197+
}
198+
],
199+
"username": "vars.ns + ':' + vars.sa",
200+
"groups": "['k8s-workloads']"
201+
}
202+
},
203+
{
204+
"issuer": "https://token.actions.githubusercontent.com",
205+
"audiences": ["zot"],
206+
"claimMapping": {
207+
"username": "claims.repository",
208+
"groups": "['github-actions']"
209+
}
210+
}
211+
]
212+
}
213+
}
214+
}
215+
}
216+
```
217+
100218
## Usage
101219

102220
### Kubernetes ServiceAccount Tokens
@@ -111,22 +229,26 @@ TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
111229
curl -H "Authorization: Bearer $TOKEN" https://zot.example.com/v2/_catalog
112230
```
113231

114-
### Flux CD Integration
232+
### Flux Integration
115233

116-
Flux CD can use OIDC workload identity to authenticate to Zot without storing credentials:
234+
Flux can use Kubernetes ServiceAccount tokens to authenticate to Zot without secrets:
117235

118236
```yaml
119237
apiVersion: source.toolkit.fluxcd.io/v1
120-
kind: HelmRepository
238+
kind: OCIRepository
121239
metadata:
122240
name: zot-repo
123241
namespace: flux-system
124242
spec:
125-
url: https://zot.example.com/v2/
126-
type: oci
127-
provider: generic
243+
url: oci://zot.example.com/v2/manifests
244+
credentials: ServiceAccountToken
245+
serviceAccountName: my-tenant-sa # optional. if omitted, defaults to the source-controller ServiceAccount
128246
```
129247
248+
Note: The configuration above is currently a proposal from the Flux maintainers
249+
and may change until officially released. For more details, see this
250+
[RFC](https://github.com/fluxcd/flux2/issues/5681).
251+
130252
### GitHub Actions
131253
132254
GitHub Actions can use OIDC tokens to authenticate:
@@ -151,21 +273,22 @@ GitHub Actions can use OIDC tokens to authenticate:
151273

152274
### Optional Claims
153275

154-
- **`groups`**: Array of group names for authorization
155-
- **`preferred_username`**: Can be used as username with claim mapping
156-
- **`email`**: Can be used as username with claim mapping
157-
- **`name`**: Can be used as username with claim mapping
276+
- **`groups`**: Array of group names for authorization (requires CEL `groups` expression)
277+
- **`preferred_username`**: Can be used as username with CEL expression
278+
- **`email`**: Can be used as username with CEL expression
279+
- **`name`**: Can be used as username with CEL expression
158280

159281
### Example Token Payload
160282

161283
```json
162284
{
163285
"iss": "https://kubernetes.default.svc.cluster.local",
164-
"aud": "zot",
165-
"sub": "system:serviceaccount:default:flux-controller",
286+
"aud": ["zot"],
287+
"sub": "system:serviceaccount:flux-system:source-controller",
166288
"exp": 1705258800,
167289
"iat": 1705255200,
168-
"groups": ["system:serviceaccounts", "system:authenticated"]
290+
"kubernetes.io/serviceaccount/namespace": "flux-system",
291+
"kubernetes.io/serviceaccount/service-account.name": "source-controller"
169292
}
170293
```
171294

@@ -213,10 +336,12 @@ OIDC workload identity can coexist with traditional bearer authentication. If bo
213336
"realm": "https://auth.myreg.io/auth/token",
214337
"service": "myauth",
215338
"cert": "/etc/zot/auth.crt",
216-
"oidc": {
217-
"issuer": "https://kubernetes.default.svc.cluster.local",
218-
"audiences": ["zot"]
219-
}
339+
"oidc": [
340+
{
341+
"issuer": "https://kubernetes.default.svc.cluster.local",
342+
"audiences": ["zot"]
343+
}
344+
]
220345
}
221346
}
222347
}
@@ -249,9 +374,13 @@ Set log level to `debug` to see detailed authentication logs:
249374

250375
3. **Token expired**: OIDC tokens are typically short-lived. Ensure your workload is obtaining fresh tokens.
251376

252-
4. **No username found**: Check the claim mapping configuration and ensure the specified claim exists in the token.
377+
4. **CEL expression error**: Check the CEL expression syntax. Use `claims.field` for simple fields or `claims['field-name']` for fields with special characters.
253378

254-
5. **JWKS endpoint not reachable**: Verify network connectivity to the OIDC issuer's JWKS endpoint.
379+
5. **Validation failed**: Check that your token claims satisfy all configured validation expressions.
380+
381+
6. **JWKS endpoint not reachable**: Verify network connectivity to the OIDC issuer's JWKS endpoint. Note: Zot lazily initializes the OIDC provider on first authentication, so startup won't fail if the issuer is temporarily unreachable.
382+
383+
7. **No username found**: Ensure the CEL expression for username evaluates to a non-empty string. Check that the required claims exist in the token.
255384

256385
## Security Considerations
257386

@@ -265,8 +394,12 @@ Set log level to `debug` to see detailed authentication logs:
265394

266395
5. **Access Control**: Always configure access control policies to limit what authenticated workloads can do.
267396

397+
6. **CEL Validations**: Use CEL validations to enforce additional security constraints (e.g., require email verification, restrict to specific organizations).
398+
268399
## References
269400

270401
- [OIDC Specification](https://openid.net/specs/openid-connect-core-1_0.html)
402+
- [CEL Language Definition](https://github.com/google/cel-spec)
271403
- [Kubernetes OIDC Authentication](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens)
272404
- [Flux Workload Identity RFC](https://github.com/fluxcd/flux2/tree/main/rfcs/0010-multi-tenant-workload-identity)
405+
- [GitHub Actions OIDC](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)

examples/config-bearer-oidc-workload.json

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,23 @@
1010
"bearer": {
1111
"realm": "zot",
1212
"service": "zot-service",
13-
"oidc": {
14-
"issuer": "https://kubernetes.default.svc.cluster.local",
15-
"audiences": ["zot", "https://zot.example.com"],
16-
"claimMapping": {
17-
"username": "sub"
13+
"oidc": [
14+
{
15+
"issuer": "https://kubernetes.default.svc.cluster.local",
16+
"audiences": ["zot", "https://zot.example.com"],
17+
"claimMapping": {
18+
"username": "claims.sub"
19+
}
1820
}
19-
}
21+
]
2022
}
2123
},
2224
"accessControl": {
2325
"repositories": {
2426
"**": {
2527
"policies": [
2628
{
27-
"users": ["system:serviceaccount:default:flux-controller"],
29+
"users": ["system:serviceaccount:flux-system:source-controller"],
2830
"actions": ["read", "create", "update", "delete"]
2931
}
3032
]

go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ require (
3535
github.com/go-viper/mapstructure/v2 v2.4.0
3636
github.com/gofrs/uuid v4.4.0+incompatible
3737
github.com/golang-jwt/jwt/v5 v5.3.0
38+
github.com/google/cel-go v0.26.1
3839
github.com/google/go-containerregistry v0.20.7
3940
github.com/google/go-github/v62 v62.0.0
4041
github.com/google/uuid v1.6.0
@@ -52,6 +53,7 @@ require (
5253
github.com/notaryproject/notation-core-go v1.3.0
5354
github.com/notaryproject/notation-go v1.3.2
5455
github.com/olekukonko/tablewriter v1.1.2
56+
github.com/onsi/gomega v1.38.2
5557
github.com/opencontainers/distribution-spec/specs-go v0.0.0-20250123160558-a139cc423184
5658
github.com/opencontainers/go-digest v1.0.0
5759
github.com/opencontainers/image-spec v1.1.1
@@ -157,6 +159,7 @@ require (
157159
github.com/aliyun/credentials-go v1.3.6 // indirect
158160
github.com/anchore/go-struct-converter v0.0.0-20230627203149-c72ef8859ca9 // indirect
159161
github.com/antithesishq/antithesis-sdk-go v0.5.0 // indirect
162+
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
160163
github.com/apparentlymart/go-cidr v1.1.0 // indirect
161164
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
162165
github.com/aquasecurity/go-gem-version v0.0.0-20201115065557-8eed6fe000ce // indirect
@@ -467,6 +470,7 @@ require (
467470
github.com/spf13/afero v1.15.0 // indirect
468471
github.com/spf13/pflag v1.0.10 // indirect
469472
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
473+
github.com/stoewer/go-strcase v1.3.1 // indirect
470474
github.com/stretchr/objx v0.5.2 // indirect
471475
github.com/subosito/gotenv v1.6.0 // indirect
472476
github.com/swaggo/files v1.0.1 // indirect

0 commit comments

Comments
 (0)