cloudzilla Pull requests #341
Add OAuth scopes to access tokens #341
Open
@daisy wants to merge 3 commits into main from daisy/oauth-scopes
4 changed files
+41
−5
| @@ -118,6 +118,14 @@ ## Access tokens | |||||
| 118 | Personal access tokens authenticate API requests with a bearer header. | ||||
| 119 | |||||
| 120 | +### Scopes | ||||
| 121 | + | ||||
| 122 | +A token carries a set of scopes. A request outside the token's scopes is rejected with `403`. | ||||
|
yumi
Nov 6, 2026
Token already has a helper for this, and it handles the wildcard.
Suggested change
Applied
In batch
line 122
daisy applied this in 4e1c0d2 · Nov 7, 2026 Committed together with the other batched suggestions. |
|||||
| 123 | + | ||||
| 124 | +| Scope | Grants | | ||||
| 125 | +| ------------ | ------------------------------- | | ||||
| 126 | +| `repo:read` | Read repositories and metadata | | ||||
| 127 | +| `repo:write` | Push, open and merge pull requests | | ||||
| 128 | + | ||||
| 129 | Tokens created before scopes existed are migrated to `scopes=*`. | ||||
| 130 | |||||
| 131 | ## Errors | ||||
| @@ -118,6 +118,14 @@ ## Access tokens | |||
| 118 | Personal access tokens authenticate API requests with a bearer header. | 118 | Personal access tokens authenticate API requests with a bearer header. |
| 119 | 119 | ||
| 120 | +### Scopes | ||
| 121 | + | ||
| 122 | +A token carries a set of scopes. A request outside the token's scopes is rejected with `403`. | ||
| 123 | + | ||
| 124 | +| Scope | Grants | | ||
| 125 | +| ------------ | ------------------------------- | | ||
| 126 | +| `repo:read` | Read repositories and metadata | | ||
| 127 | +| `repo:write` | Push, open and merge pull requests | | ||
| 128 | + | ||
| 120 | Tokens created before scopes existed are migrated to `scopes=*`. | 129 | Tokens created before scopes existed are migrated to `scopes=*`. |
| 121 | 130 | ||
| 122 | ## Errors | 131 | ## Errors |
| @@ -0,0 +1,12 @@ | |
| 1 | +CREATE TABLE access_token_scopes ( |
| 2 | + id BIGSERIAL PRIMARY KEY, |
| 3 | + access_token_id BIGINT NOT NULL REFERENCES access_tokens (id) ON DELETE CASCADE, |
| 4 | + scope TEXT NOT NULL, |
| 5 | + UNIQUE (access_token_id, scope) |
| 6 | +); |
| 7 | + |
| 8 | +INSERT INTO access_token_scopes (access_token_id, scope) |
| 9 | +SELECT id, '*' FROM access_tokens; |
| 10 | + |
| 11 | +CREATE INDEX idx_access_token_scopes_token ON access_token_scopes (access_token_id); |
| 12 | + |
| @@ -0,0 +1,12 @@ | |
| 1 | +CREATE TABLE access_token_scopes ( |
| 2 | + id BIGSERIAL PRIMARY KEY, |
| 3 | + access_token_id BIGINT NOT NULL REFERENCES access_tokens (id) ON DELETE CASCADE, |
| 4 | + scope TEXT NOT NULL, |
| 5 | + UNIQUE (access_token_id, scope) |
| 6 | +); |
| 7 | + |
| 8 | +INSERT INTO access_token_scopes (access_token_id, scope) |
| 9 | +SELECT id, '*' FROM access_tokens; |
| 10 | + |
| 11 | +CREATE INDEX idx_access_token_scopes_token ON access_token_scopes (access_token_id); |
| 12 | + |
| @@ -68,12 +68,24 @@ func RequireAuth(next http.Handler) http.Handler { | |
| 68 | return |
| 69 | } |
| 70 | |
| 71 | - next.ServeHTTP(w, r) |
| 71 | + next.ServeHTTP(w, r) |
| 72 | }) |
| 73 | } |
| 74 | |
| 75 | +// RequireScope rejects requests whose token lacks the scope. |
| 76 | +func RequireScope(scope string) func(http.Handler) http.Handler { |
| 77 | + return func(next http.Handler) http.Handler { |
| 78 | + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 79 | + token := TokenFromContext(r.Context()) |
| 80 | + if !slices.Contains(token.Scopes, scope) && !slices.Contains(token.Scopes, "*") { |
|
yumi
May 13, 2026
Unscoped legacy tokens now get |
|
| 81 | + http.Error(w, "missing scope", http.StatusForbidden) |
| 82 | + return |
| 83 | + } |
| 84 | + next.ServeHTTP(w, r) |
| 85 | + }) |
| 86 | + } |
| 87 | +} |
| 88 | |
| 89 | func TokenFromContext(ctx context.Context) *model.AccessToken { |
| @@ -88,7 +100,3 @@ func TokenFromContext(ctx context.Context) *model.AccessToken { | |
| 100 | return t |
| 101 | } |
| 102 | |
| 91 | -// legacyAllScopes treated a token with no scopes as unrestricted. |
| 92 | -func legacyAllScopes(t *model.AccessToken) bool { |
| 93 | - return len(t.Scopes) == 0 |
| 94 | -} |
| @@ -68,12 +68,24 @@ func RequireAuth(next http.Handler) http.Handler { | |||
| 68 | return | 68 | return |
| 69 | } | 69 | } |
| 70 | 70 | ||
| 71 | - next.ServeHTTP(w, r) | 71 | + next.ServeHTTP(w, r) |
| 72 | }) | 72 | }) |
| 73 | } | 73 | } |
| 74 | 74 | ||
| 75 | +// RequireScope rejects requests whose token lacks the scope. | ||
| 76 | +func RequireScope(scope string) func(http.Handler) http.Handler { | ||
| 77 | + return func(next http.Handler) http.Handler { | ||
| 78 | + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 79 | + token := TokenFromContext(r.Context()) | ||
| 80 | + if !slices.Contains(token.Scopes, scope) && !slices.Contains(token.Scopes, "*") { | ||
| 81 | + http.Error(w, "missing scope", http.StatusForbidden) | ||
| 82 | + return | ||
| 83 | + } | ||
| 84 | + next.ServeHTTP(w, r) | ||
| 85 | + }) | ||
| 86 | + } | ||
| 87 | +} | ||
| 75 | 88 | ||
| 76 | func TokenFromContext(ctx context.Context) *model.AccessToken { | 89 | func TokenFromContext(ctx context.Context) *model.AccessToken { |
| @@ -88,7 +100,3 @@ func TokenFromContext(ctx context.Context) *model.AccessToken { | |||
| 88 | return t | 100 | return t |
| 89 | } | 101 | } |
| 90 | 102 | ||
| 91 | -// legacyAllScopes treated a token with no scopes as unrestricted. | ||
| 92 | -func legacyAllScopes(t *model.AccessToken) bool { | ||
| 93 | - return len(t.Scopes) == 0 | ||
| 94 | -} | ||
| @@ -40,6 +40,13 @@ func (s *TokenStore) Create(ctx context.Context, t model.AccessToken) error { | |
| 40 | err := s.db.QueryRowContext(ctx, `INSERT INTO access_tokens (user_id, name, hash) |
| 41 | VALUES ($1, $2, $3) RETURNING id`, t.UserID, t.Name, t.Hash).Scan(&t.ID) |
| 42 | if err != nil { |
| 43 | return err |
| 44 | } |
| 45 | + for _, scope := range t.Scopes { |
| 46 | + if _, err := s.db.ExecContext(ctx, `INSERT INTO access_token_scopes (access_token_id, scope) |
| 47 | + VALUES ($1, $2)`, t.ID, scope); err != nil { |
| 48 | + return err |
| 49 | + } |
| 50 | + } |
| 51 | return nil |
| @@ -40,6 +40,13 @@ func (s *TokenStore) Create(ctx context.Context, t model.AccessToken) error { | |||
| 40 | err := s.db.QueryRowContext(ctx, `INSERT INTO access_tokens (user_id, name, hash) | 40 | err := s.db.QueryRowContext(ctx, `INSERT INTO access_tokens (user_id, name, hash) |
| 41 | VALUES ($1, $2, $3) RETURNING id`, t.UserID, t.Name, t.Hash).Scan(&t.ID) | 41 | VALUES ($1, $2, $3) RETURNING id`, t.UserID, t.Name, t.Hash).Scan(&t.ID) |
| 42 | if err != nil { | 42 | if err != nil { |
| 43 | return err | 43 | return err |
| 44 | } | 44 | } |
| 45 | + for _, scope := range t.Scopes { | ||
| 46 | + if _, err := s.db.ExecContext(ctx, `INSERT INTO access_token_scopes (access_token_id, scope) | ||
| 47 | + VALUES ($1, $2)`, t.ID, scope); err != nil { | ||
| 48 | + return err | ||
| 49 | + } | ||
| 50 | + } | ||
| 45 | return nil | 51 | return nil |