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

Review changes

Line comments you add on the Files tab are posted as you write them. This adds your summary and verdict to #341.

docs/api-reference.md +9 View file →
@@ -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 line 122
122-A token carries a set of scopes. A request outside the token's scopes is rejected with `403`.
122+A token carries a set of scopes (`*` grants all). A request outside them 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 +
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 ## Errors131 ## Errors
internal/db/migrations/0042_access_token_scopes.sql +12 View file →
@@ -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+
internal/middleware/auth.go +14 −5 View file →
@@ -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 * from the migration, so this branch is safe. Thanks for dropping legacyAllScopes.

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 return68 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 t100 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-}
internal/store/token_store.go +6 View file →
@@ -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 err43 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 nil51 return nil