Skip to content

Commit 73e724a

Browse files
authored
auth: remove role management, rely on CMID and IDP (#30)
auth: remove role scoping, using CMID only
1 parent c7f9b4a commit 73e724a

23 files changed

Lines changed: 447 additions & 603 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
## Protocol Boundaries
2121

22-
- The server is an OAuth 2.0 protected resource; list/download routes are public and mutations require operation-specific scopes.
22+
- The server is an OAuth 2.0 protected resource; list/download routes are public and mutations require operation-specific scopes. Identity-provider client restrictions, not local user/role mappings, determine who receives those scopes.
2323
- The CLI discovers RFC 9728 protected-resource metadata and then OIDC metadata. Generic RFC 8414-only providers and direct endpoint overrides are unsupported.
2424
- Keep OAuth mechanics in `golang.org/x/oauth2`, OIDC discovery/validation in `go-oidc`, URL trust rules in `internal/urlpolicy`, and authorization policy in `internal/auth`.
2525
- `PKGDEPOT_OIDC_*` configures the server; `PKGDEPOT_OAUTH_*` configures CLI clients. Do not interchange them.

README.md

Lines changed: 26 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -85,23 +85,26 @@ client specified with `PKGDEPOT_OAUTH_CLIENT_ID`. Pre-registered clients must
8585
allow these loopback callbacks:
8686

8787
```text
88-
http://127.0.0.1:8085/oauth/callback
89-
http://127.0.0.1:8086/oauth/callback
90-
http://127.0.0.1:8087/oauth/callback
91-
http://127.0.0.1:8088/oauth/callback
92-
http://127.0.0.1:8089/oauth/callback
88+
http://127.0.0.1/oauth/callback
89+
http://[::1]/oauth/callback
9390
```
9491

95-
Delegated access tokens need both the requested OAuth scope and a role mapped to
96-
that scope. By default, roles are read from the `pkgdepot_roles` claim:
92+
The CLI selects IPv4 or IPv6 and binds an ephemeral port for each login. Do not
93+
register fixed ports; the provider matches these loopback redirect URIs with a
94+
variable port.
9795

98-
```json
99-
{"pkgdepot_roles":["publisher"]}
100-
```
96+
Authorization is controlled by the identity provider's client restrictions and
97+
resource scopes. pkgdepot publishes two CIMD clients:
98+
99+
| Client | CIMD client ID path | Intended group | Scopes |
100+
| --- | --- | --- | --- |
101+
| Publisher | `/oauth/clients/cli-publisher` | `pkgdepot publishers` | `package:publish` |
102+
| Admin | `/oauth/clients/cli-admin` | `pkgdepot administrators` | All mutation scopes |
101103

102-
The built-in `admin` role grants every mutation scope. `publisher` grants only
103-
`package:publish`. Configure a different claim or mapping with
104-
`PKGDEPOT_ROLE_CLAIM` and `PKGDEPOT_ROLE_SCOPES`.
104+
Restrict each client to its corresponding provider group. pkgdepot does not
105+
read user roles or map role claims; a validated access token with the requested
106+
operation scope is authorized. The token audience remains the canonical
107+
`PKGDEPOT_URL`, not either CIMD client ID.
105108

106109
### Delegated CLI login
107110

@@ -111,22 +114,22 @@ HTTPS deployment with a provider that supports CIMD:
111114
```sh
112115
export PKGDEPOT_URL=https://packages.example.com
113116

114-
pkgdepot login --scope repo:create --scope package:publish
117+
pkgdepot login --access admin --scope repo:create --scope package:publish
115118
pkgdepot repo create stable
116119
pkgdepot package publish stable ./example-1.0-1-x86_64.pkg.tar.zst
117120
```
118121

119-
The CLI prints URLs for identity verification and scope selection. Run
120-
`pkgdepot logout` to delete its cached delegated token. For a provider without
121-
CIMD, set `PKGDEPOT_OAUTH_CLIENT_ID` to a registered client ID before login.
122-
For the default local HTTP URL, a pre-registered client ID is always required.
122+
The CLI prints URLs for identity verification and scope selection. Use
123+
`--access publisher` or `--access admin` to select a CIMD client; administrative
124+
scopes select the admin client automatically. Run `pkgdepot logout` to delete
125+
cached delegated tokens. For a provider without CIMD, set
126+
`PKGDEPOT_OAUTH_CLIENT_ID` to a registered client ID before login. For the
127+
default local HTTP URL, a pre-registered client ID is always required.
123128

124129
### Automation with client credentials
125130

126131
For headless or containerized automation, configure a confidential client. The
127-
server must set `PKGDEPOT_CLIENT_CREDENTIALS_SUBJECT_TEMPLATE` to the format of
128-
the provider's client-credentials subject, for example `client-{client_id}`.
129-
The client needs an ID, secret, and issuer pin:
132+
client needs an ID, secret, issuer pin, and the required operation scopes:
130133

131134
```sh
132135
PKGDEPOT_URL=https://packages.example.com \
@@ -147,13 +150,12 @@ To use Pocket ID with client credentials:
147150
1. Create a `pkgdepot` API whose resource is the exact public `PKGDEPOT_URL`.
148151
2. Add the required operation scopes, such as `package:publish` and `package:remove`.
149152
3. Create a confidential OIDC client and grant its API access under **Client access**.
150-
4. Start pkgdepot with the Pocket ID issuer and a matching subject template.
153+
4. Start pkgdepot with the Pocket ID issuer.
151154
5. Configure the CLI with the confidential client credentials as shown above.
152155

153156
```sh
154157
PKGDEPOT_URL=https://packages.example.com \
155158
PKGDEPOT_OIDC_ISSUER=https://id.example.com \
156-
PKGDEPOT_CLIENT_CREDENTIALS_SUBJECT_TEMPLATE=client-{client_id} \
157159
pkgdepot serve
158160
```
159161

@@ -228,15 +230,12 @@ sudo pacman -Syu example
228230
| `PKGDEPOT_OIDC_AUDIENCE` | `PKGDEPOT_URL` | Expected access-token audience. |
229231
| `PKGDEPOT_OIDC_JWT_ALGORITHMS` | `RS256` | Allowed access-token signing algorithms. |
230232
| `PKGDEPOT_OIDC_JWT_CACHE_LIFETIME` | `15m` | Maximum signing-key-set trust lifetime. |
231-
| `PKGDEPOT_ROLE_CLAIM` | `pkgdepot_roles` | Access-token claim containing roles. |
232-
| `PKGDEPOT_ROLE_SCOPES` | Built-in admin/publisher mapping | JSON object mapping roles to operation scopes. |
233-
| `PKGDEPOT_CLIENT_CREDENTIALS_SUBJECT_TEMPLATE` | Disabled | Client-credentials subject format containing one `{client_id}`. |
234233

235234
### CLI OAuth
236235

237236
| Variable | Default | Description |
238237
| --- | --- | --- |
239-
| `PKGDEPOT_OAUTH_CLIENT_ID` | Empty | Required for client credentials and delegated clients without CIMD. HTTPS delegated clients with CIMD derive the server metadata URL when it is empty. |
238+
| `PKGDEPOT_OAUTH_CLIENT_ID` | Empty | Required for client credentials and delegated clients without CIMD. HTTPS delegated clients with CIMD derive the publisher or admin CIMD URL. |
240239
| `PKGDEPOT_OAUTH_CLIENT_SECRET` | Empty | Enables client-credentials authentication; omit for delegated login. |
241240
| `PKGDEPOT_OAUTH_ISSUER` | Required with a client secret | Expected issuer pin before credentials are sent. |
242241

cmd/pkgdepot/login.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,17 @@ func loginCommand() *cli.Command {
1919
Name: "scope",
2020
Usage: "OAuth scope to request; may be repeated",
2121
Value: append([]string(nil), defaultOAuthScopes...),
22+
}, &cli.StringFlag{
23+
Name: "access",
24+
Usage: "OAuth access profile (publisher or admin)",
2225
}),
2326
Action: login,
2427
}
2528
}
2629

2730
func login(ctx context.Context, cmd *cli.Command) error {
2831
client := httpclient.New(ctx, cmd.String("url"))
32+
client.OAuth.Access = cmd.String("access")
2933
token, err := client.Login(ctx, cmd.StringSlice("scope"))
3034
if err != nil {
3135
return err

cmd/pkgdepot/serve.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ func resourceServer(cfg config.Config) (*auth.ResourceServer, error) {
7171
audience = resourceURL
7272
}
7373
discoveryCtx := oidc.ClientContext(context.Background(), &http.Client{Timeout: cfg.HTTPTimeout})
74-
validator, err := auth.NewOIDCValidator(discoveryCtx, auth.OIDCOptions{Issuer: cfg.Auth.Issuer, Audience: audience, Algorithms: cfg.Auth.Algorithms, KeyCacheLifetime: cfg.Auth.KeyCacheLifetime, RoleClaim: cfg.Auth.RoleClaim})
74+
validator, err := auth.NewOIDCValidator(discoveryCtx, auth.OIDCOptions{Issuer: cfg.Auth.Issuer, Audience: audience, Algorithms: cfg.Auth.Algorithms, KeyCacheLifetime: cfg.Auth.KeyCacheLifetime})
7575
if err != nil {
7676
return nil, err
7777
}
@@ -94,7 +94,7 @@ func resourceServerWithValidator(cfg config.Config, validator auth.Validator) *a
9494
BearerMethodsSupported: []string{"header"},
9595
},
9696
Authorize: func(claims auth.Claims, scope, _, _ string) bool {
97-
return auth.AuthorizeRoles(claims, scope, cfg.Auth.RoleScopes, cfg.Auth.ClientCredentialsSubjectTemplate)
97+
return auth.HasScope(claims, scope)
9898
},
9999
}
100100
}

cmd/pkgdepot/serve_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@ func TestResourceServerDefaultsAudienceToResourceURL(t *testing.T) {
2222
})
2323
}))
2424
defer issuer.Close()
25-
resource, err := resourceServer(config.Config{URL: "https://packages.example", HTTPTimeout: time.Second, Auth: config.OIDCConfig{Issuer: issuer.URL, RoleScopes: map[string][]string{"publisher": {auth.ScopePublish}}, ClientCredentialsSubjectTemplate: "{client_id}"}})
25+
resource, err := resourceServer(config.Config{URL: "https://packages.example", HTTPTimeout: time.Second, Auth: config.OIDCConfig{Issuer: issuer.URL}})
2626
if err != nil {
2727
t.Fatal(err)
2828
}
29-
if resource.Metadata.Resource != "https://packages.example" || !resource.Authorize(auth.Claims{Roles: []string{"publisher"}, Scopes: []string{auth.ScopePublish}}, auth.ScopePublish, "stable", "x86_64") {
30-
t.Fatal("resource server did not wire role authorization")
29+
if resource.Metadata.Resource != "https://packages.example" || !resource.Authorize(auth.Claims{Scopes: []string{auth.ScopePublish}}, auth.ScopePublish, "stable", "x86_64") {
30+
t.Fatal("resource server did not wire scope authorization")
3131
}
3232
if !resource.Authorize(auth.Claims{Scopes: []string{auth.ScopePublish}, Subject: "app", ClientID: "app"}, auth.ScopePublish, "stable", "x86_64") {
3333
t.Fatal("resource server rejected a client-credentials scope")

internal/auth/AGENTS.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,14 @@
55
- Validate RFC 9068 access tokens, not generic JWTs or ID tokens. Require a three-part JWT with case-insensitive `typ` exactly `at+jwt` or `application/at+jwt`.
66
- Enforce issuer, audience, expiry, signature, allowed algorithms, and nonempty `sub`, `client_id`, and `jti`, plus a positive numeric `iat`. The default algorithm is RS256 only.
77
- `scp` must be a JSON string array; `scope` is whitespace-delimited. An absent `scp` is valid, but a scalar is not.
8-
- The role claim defaults to `pkgdepot_roles`. Missing or `null` means no roles; any other present non-string-array value invalidates the token.
98
- Do not expose verifier details: log the underlying cause and return `ErrInvalidToken`.
109
- Signing-key trust expires from key-set creation and defaults to 15 minutes. Replace the remote key set once under its mutex; do not extend trust on successful traffic.
1110

1211
## Authorization
1312

14-
- Delegated tokens need both the requested OAuth scope and a role mapped to it. A mapped role alone is insufficient; unknown roles grant nothing.
15-
- Role-less tokens are denied unless the configured client-credentials subject template exactly matches `sub` after `{client_id}` substitution. Tokens with roles always use role mapping.
13+
- Authorization is scope-only: a validated token is allowed when it contains the requested operation scope. User/group and client restrictions belong to the identity provider.
1614
- Bearer parsing distinguishes missing credentials from malformed credentials; HTTP integration relies on that distinction for challenges and OAuth error codes.
17-
- Preserve the five mutation scope constants and built-in mapping: `admin` gets all; `publisher` gets only `package:publish`.
15+
- Preserve the five mutation scope constants.
1816

1917
## Tests
2018

internal/auth/auth.go

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -22,25 +22,15 @@ const (
2222
ScopeRepositoryCreate = "repo:create"
2323
ScopeRepositoryRemove = "repo:remove"
2424
ScopeRepositoryRename = "repo:rename"
25-
DefaultRoleClaim = "pkgdepot_roles"
2625
)
2726

2827
// Claims are the access-token claims relevant to a protected resource server.
2928
type Claims struct {
3029
Scopes []string
31-
Roles []string
3230
Subject string
3331
ClientID string
3432
}
3533

36-
// DefaultRoleScopes returns the built-in roles for a new PKGdepot server.
37-
func DefaultRoleScopes() map[string][]string {
38-
return map[string][]string{
39-
"admin": {ScopePublish, ScopeRemove, ScopeRepositoryCreate, ScopeRepositoryRemove, ScopeRepositoryRename},
40-
"publisher": {ScopePublish},
41-
}
42-
}
43-
4434
type Validator interface {
4535
Validate(context.Context, string) (Claims, error)
4636
}
@@ -51,7 +41,6 @@ type OIDCOptions struct {
5141
Audience string
5242
Algorithms []string
5343
KeyCacheLifetime time.Duration
54-
RoleClaim string
5544
}
5645

5746
// NewOIDCValidator creates an OIDC-discovered signed JWT access-token validator.
@@ -80,38 +69,6 @@ func HasScope(claims Claims, scope string) bool {
8069
return slices.Contains(claims.Scopes, scope)
8170
}
8271

83-
// AuthorizeRoles reports whether the claims grant the requested scope. User
84-
// tokens must have both the OAuth scope and a role that permits it. Role-less
85-
// client-credentials tokens may authorize from their OAuth scopes when their
86-
// subject matches subjectTemplate.
87-
func AuthorizeRoles(claims Claims, scope string, roleScopes map[string][]string, subjectTemplate string) bool {
88-
if len(claims.Roles) == 0 {
89-
if subjectTemplate == "" || !MatchClientCredentialsSubject(claims, subjectTemplate) {
90-
return false
91-
}
92-
return HasScope(claims, scope)
93-
}
94-
if !HasScope(claims, scope) {
95-
return false
96-
}
97-
for _, role := range claims.Roles {
98-
if slices.Contains(roleScopes[role], scope) {
99-
return true
100-
}
101-
}
102-
return false
103-
}
104-
105-
// MatchClientCredentialsSubject reports whether the token subject matches the
106-
// configured client-credentials template. The template must contain exactly one
107-
// {client_id} placeholder, which is expanded to the signed client_id claim.
108-
func MatchClientCredentialsSubject(claims Claims, subjectTemplate string) bool {
109-
if claims.Subject == "" || subjectTemplate == "" {
110-
return false
111-
}
112-
return claims.Subject == strings.ReplaceAll(subjectTemplate, "{client_id}", claims.ClientID)
113-
}
114-
11572
func BearerToken(header string) (string, error) {
11673
if strings.TrimSpace(header) == "" {
11774
return "", ErrMissingCredentials

internal/auth/auth_test.go

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -29,38 +29,3 @@ func TestHasScope(t *testing.T) {
2929
t.Fatal("undeclared scope was authorized")
3030
}
3131
}
32-
33-
func TestAuthorizeRoles(t *testing.T) {
34-
roleScopes := map[string][]string{
35-
"admin": {auth.ScopePublish, auth.ScopeRemove},
36-
"publisher": {auth.ScopePublish},
37-
"viewer": {},
38-
}
39-
for name, test := range map[string]struct {
40-
roles []string
41-
scopes []string
42-
subject string
43-
clientID string
44-
subjectTemplate string
45-
scope string
46-
want bool
47-
}{
48-
"role grants scope": {roles: []string{"publisher"}, scopes: []string{auth.ScopePublish}, scope: auth.ScopePublish, want: true},
49-
"role grant without token scope": {roles: []string{"publisher"}, scope: auth.ScopePublish},
50-
"role lacks scope": {roles: []string{"publisher"}, scope: auth.ScopeRemove},
51-
"one of multiple roles": {roles: []string{"viewer", "admin"}, scopes: []string{auth.ScopeRemove}, scope: auth.ScopeRemove, want: true},
52-
"unknown role": {roles: []string{"unknown"}, scope: auth.ScopePublish},
53-
"no roles": {scope: auth.ScopePublish},
54-
"client credentials scope": {subject: "app", clientID: "app", subjectTemplate: "{client_id}", scopes: []string{auth.ScopePublish}, scope: auth.ScopePublish, want: true},
55-
"pocket id client credentials": {subject: "client-app", clientID: "app", subjectTemplate: "client-{client_id}", scopes: []string{auth.ScopePublish}, scope: auth.ScopePublish, want: true},
56-
"delegated scope forbidden": {subject: "user-1", clientID: "app", subjectTemplate: "{client_id}", scopes: []string{auth.ScopePublish}, scope: auth.ScopePublish},
57-
"template disabled": {subject: "app", clientID: "app", subjectTemplate: "", scopes: []string{auth.ScopePublish}, scope: auth.ScopePublish},
58-
"mismatched subject forbidden": {subject: "other", clientID: "app", subjectTemplate: "{client_id}", scopes: []string{auth.ScopePublish}, scope: auth.ScopePublish},
59-
} {
60-
t.Run(name, func(t *testing.T) {
61-
if got := auth.AuthorizeRoles(auth.Claims{Roles: test.roles, Scopes: test.scopes, Subject: test.subject, ClientID: test.clientID}, test.scope, roleScopes, test.subjectTemplate); got != test.want {
62-
t.Fatalf("AuthorizeRoles() = %t, want %t", got, test.want)
63-
}
64-
})
65-
}
66-
}

internal/auth/jwt.go

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ type oidcVerifier interface {
1818
}
1919

2020
type discoveredVerifier struct {
21-
verifier *oidc.IDTokenVerifier
22-
roleClaim string
21+
verifier *oidc.IDTokenVerifier
2322
}
2423

2524
const defaultKeyCacheLifetime = 15 * time.Minute
@@ -100,15 +99,11 @@ func newOIDCVerifier(ctx context.Context, options OIDCOptions) (*discoveredVerif
10099
if len(algorithms) == 0 {
101100
algorithms = []string{"RS256"}
102101
}
103-
roleClaim := options.RoleClaim
104-
if roleClaim == "" {
105-
roleClaim = DefaultRoleClaim
106-
}
107102
keySet := newExpiringKeySet(ctx, metadata.JWKSURL, options.KeyCacheLifetime)
108103
return &discoveredVerifier{verifier: oidc.NewVerifier(options.Issuer, keySet, &oidc.Config{
109104
ClientID: options.Audience,
110105
SupportedSigningAlgs: algorithms,
111-
}), roleClaim: roleClaim}, nil
106+
})}, nil
112107
}
113108

114109
func (v *discoveredVerifier) Verify(ctx context.Context, value string) (Claims, error) {
@@ -136,26 +131,7 @@ func (v *discoveredVerifier) Verify(ctx context.Context, value string) (Claims,
136131
if issuedAt, err := raw.IssuedAt.Int64(); err != nil || issuedAt <= 0 {
137132
return Claims{}, fmt.Errorf("access token has an invalid iat claim")
138133
}
139-
var allClaims map[string]json.RawMessage
140-
if err := token.Claims(&allClaims); err != nil {
141-
return Claims{}, err
142-
}
143-
roles, err := stringSliceClaim(allClaims[v.roleClaim])
144-
if err != nil {
145-
return Claims{}, fmt.Errorf("access token has an invalid %q claim: %w", v.roleClaim, err)
146-
}
147-
return Claims{Scopes: append(raw.Scopes, strings.Fields(raw.Scope)...), Roles: roles, Subject: raw.Subject, ClientID: raw.ClientID}, nil
148-
}
149-
150-
func stringSliceClaim(value json.RawMessage) ([]string, error) {
151-
if len(value) == 0 || string(value) == "null" {
152-
return nil, nil
153-
}
154-
var values []string
155-
if err := json.Unmarshal(value, &values); err != nil {
156-
return nil, err
157-
}
158-
return values, nil
134+
return Claims{Scopes: append(raw.Scopes, strings.Fields(raw.Scope)...), Subject: raw.Subject, ClientID: raw.ClientID}, nil
159135
}
160136

161137
func validateAccessTokenHeader(value string) error {

0 commit comments

Comments
 (0)