Skip to content

Commit f978dd6

Browse files
authored
Persist OAuth project scope (#216)
## summary - persist authoritative organization/project scope returned by the OAuth server - preserve scope across automatic refresh and treat legacy credentials as organization-wide - show the active OAuth scope in `kernel auth` - document organization-wide and project-scoped login behavior ## tests - `go test ./...` - `go vet ./...` - `go build ./...` ## dependency Pairs with kernel/kernel-mcp-server#138. The fields are optional, so this remains compatible with the currently deployed OAuth server. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes OAuth token handling and stored credential shape; behavior is backward compatible for legacy tokens but affects how scope is enforced for all OAuth sessions after login or refresh. > > **Overview** > Adds **organization-wide vs project-scoped OAuth** to the CLI by persisting `access_scope` and `project_id` on stored credentials. > > During login, the callback may carry scope in OAuth `state`; after code exchange, **token response fields override** state for `org_id`, `access_scope`, and `project_id`. Missing scope defaults to organization-wide; org-wide tokens clear `project_id`. The same fields are updated on **refresh**, with legacy tokens without scope fields treated as organization-wide. > > **`kernel auth`** prints whether the session is organization-wide or locked to a project ID. README documents choosing scope at login and that project-scoped tokens cannot use `--project` to switch projects. > > Tests cover authoritative scope from token exchange, refresh preserving project scope, and legacy refresh behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c9eb41d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 77a6def commit f978dd6

5 files changed

Lines changed: 200 additions & 12 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ The easiest way to authenticate is using OAuth:
8484
kernel login
8585
```
8686

87-
This opens your browser to complete the authentication flow. Your credentials are securely stored and automatically refreshed.
87+
This opens your browser to complete the authentication flow. Choose organization-wide access or restrict the login to one project. Your credentials are securely stored, automatically refreshed, and retain the selected scope until you log in again.
8888

8989
### API Key
9090

@@ -103,7 +103,7 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com).
103103
- `--version`, `-v` - Print the CLI version
104104
- `--no-color` - Disable color output
105105
- `--log-level <level>` - Set log level (trace, debug, info, warn, error, fatal, print)
106-
- `--project <project-id>` - Scope requests to a project ID (or set `KERNEL_PROJECT` to a project ID)
106+
- `--project <project-id>` - Scope requests to a project ID (or set `KERNEL_PROJECT` to a project ID). Project-scoped OAuth tokens cannot switch projects.
107107

108108
## JSON Output
109109

‎cmd/auth.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ func runAuth(cmd *cobra.Command, args []string) error {
108108
pterm.Info.Printf("API URL: %s\n", util.GetBaseURL())
109109
pterm.Info.Printf("Auth URL: %s\n", tokenAuthBaseURLForDisplay(tokens))
110110
pterm.Info.Printf("OAuth client ID: %s\n", maskClientID(tokenOAuthClientIDForDisplay(tokens)))
111+
if tokens.AccessScope == "project" && tokens.ProjectID != "" {
112+
pterm.Info.Printf("Access scope: project %s\n", tokens.ProjectID)
113+
} else {
114+
pterm.Info.Println("Access scope: organization-wide")
115+
}
111116

112117
// Extract info from JWT token
113118
if claims, err := parseJWT(tokens.AccessToken); err == nil && claims != nil {

‎pkg/auth/oauth.go‎

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,16 @@ type TokenResponse struct {
6767
ExpiresIn int `json:"expires_in"`
6868
TokenType string `json:"token_type"`
6969
OrgID string `json:"org_id"`
70+
AccessScope string `json:"access_scope"`
71+
ProjectID string `json:"project_id"`
7072
}
7173

7274
// AuthResult represents the result data passed through the callback channel
7375
type AuthResult struct {
74-
Code string `json:"code"`
75-
OrgID string `json:"org_id,omitempty"`
76+
Code string `json:"code"`
77+
OrgID string `json:"org_id,omitempty"`
78+
AccessScope string `json:"access_scope,omitempty"`
79+
ProjectID string `json:"project_id,omitempty"`
7680
}
7781

7882
// CurrentAuthBaseURL returns the OAuth server base URL for new login flows.
@@ -210,7 +214,7 @@ func (oc *OAuthConfig) StartOAuthFlow(ctx context.Context) (*TokenStorage, error
210214
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
211215
// Extract and decode state parameter to get CSRF token and org_id
212216
encodedState := r.URL.Query().Get("state")
213-
var csrfToken, orgID string
217+
var csrfToken, orgID, accessScope, projectID string
214218

215219
if encodedState != "" {
216220
// Try to decode the state parameter
@@ -219,6 +223,8 @@ func (oc *OAuthConfig) StartOAuthFlow(ctx context.Context) (*TokenStorage, error
219223
if json.Unmarshal(decodedBytes, &stateData) == nil {
220224
csrfToken = stateData["csrf"]
221225
orgID = stateData["org_id"]
226+
accessScope = stateData["access_scope"]
227+
projectID = stateData["project_id"]
222228
}
223229
}
224230

@@ -258,8 +264,10 @@ func (oc *OAuthConfig) StartOAuthFlow(ctx context.Context) (*TokenStorage, error
258264

259265
// Pass both code and org_id to the channel using JSON encoding
260266
result := AuthResult{
261-
Code: code,
262-
OrgID: orgID,
267+
Code: code,
268+
OrgID: orgID,
269+
AccessScope: accessScope,
270+
ProjectID: projectID,
263271
}
264272
resultJSON, err := json.Marshal(result)
265273
if err != nil {
@@ -279,7 +287,7 @@ func (oc *OAuthConfig) StartOAuthFlow(ctx context.Context) (*TokenStorage, error
279287
}()
280288

281289
// Wait for callback or timeout
282-
var authCode, orgID string
290+
var authCode, orgID, accessScope, projectID string
283291
select {
284292
case resultJSON := <-codeChan:
285293
// Success - shutdown server
@@ -291,6 +299,8 @@ func (oc *OAuthConfig) StartOAuthFlow(ctx context.Context) (*TokenStorage, error
291299
}
292300
authCode = result.Code
293301
orgID = result.OrgID
302+
accessScope = result.AccessScope
303+
projectID = result.ProjectID
294304
case err := <-errChan:
295305
server.Shutdown(context.Background())
296306
return nil, err
@@ -303,11 +313,11 @@ func (oc *OAuthConfig) StartOAuthFlow(ctx context.Context) (*TokenStorage, error
303313
}
304314

305315
// Exchange authorization code for tokens
306-
return oc.exchangeCodeForTokens(ctx, authCode, orgID)
316+
return oc.exchangeCodeForTokens(ctx, authCode, orgID, accessScope, projectID)
307317
}
308318

309319
// exchangeCodeForTokens exchanges the authorization code for access and refresh tokens
310-
func (oc *OAuthConfig) exchangeCodeForTokens(ctx context.Context, code, orgID string) (*TokenStorage, error) {
320+
func (oc *OAuthConfig) exchangeCodeForTokens(ctx context.Context, code, orgID, accessScope, projectID string) (*TokenStorage, error) {
311321
// Use PKCE verifier in token exchange, and include org_id if available
312322
var opts []oauth2.AuthCodeOption
313323
opts = append(opts, oauth2.SetAuthURLParam("code_verifier", oc.Verifier))
@@ -323,11 +333,29 @@ func (oc *OAuthConfig) exchangeCodeForTokens(ctx context.Context, code, orgID st
323333
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
324334
}
325335

336+
if value, ok := token.Extra("org_id").(string); ok && value != "" {
337+
orgID = value
338+
}
339+
if value, ok := token.Extra("access_scope").(string); ok && value != "" {
340+
accessScope = value
341+
}
342+
if value, ok := token.Extra("project_id").(string); ok {
343+
projectID = value
344+
}
345+
if accessScope == "" {
346+
accessScope = "organization"
347+
}
348+
if accessScope == "organization" {
349+
projectID = ""
350+
}
351+
326352
return &TokenStorage{
327353
AccessToken: token.AccessToken,
328354
RefreshToken: token.RefreshToken,
329355
ExpiresAt: token.Expiry,
330356
OrgID: orgID,
357+
AccessScope: accessScope,
358+
ProjectID: projectID,
331359
AuthBaseURL: oc.AuthBaseURL,
332360
OAuthClientID: oc.OAuthClientID,
333361
}, nil
@@ -388,11 +416,32 @@ func RefreshTokens(ctx context.Context, tokens *TokenStorage) (*TokenStorage, er
388416
// Add extra fields
389417
newToken = newToken.WithExtra(tokenResponse)
390418

419+
orgID := tokens.OrgID
420+
if value, ok := tokenResponse["org_id"].(string); ok && value != "" {
421+
orgID = value
422+
}
423+
accessScope := tokens.AccessScope
424+
if value, ok := tokenResponse["access_scope"].(string); ok && value != "" {
425+
accessScope = value
426+
}
427+
if accessScope == "" {
428+
accessScope = "organization"
429+
}
430+
projectID := tokens.ProjectID
431+
if value, ok := tokenResponse["project_id"].(string); ok {
432+
projectID = value
433+
}
434+
if accessScope == "organization" {
435+
projectID = ""
436+
}
437+
391438
return &TokenStorage{
392439
AccessToken: newToken.AccessToken,
393440
RefreshToken: newToken.RefreshToken,
394441
ExpiresAt: newToken.Expiry,
395-
OrgID: tokens.OrgID,
442+
OrgID: orgID,
443+
AccessScope: accessScope,
444+
ProjectID: projectID,
396445
AuthBaseURL: tokenAuthBaseURL(tokens),
397446
OAuthClientID: tokenOAuthClientID(tokens),
398447
}, nil

‎pkg/auth/oauth_test.go‎

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
package auth
22

3-
import "testing"
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
"time"
10+
11+
"golang.org/x/oauth2"
12+
)
413

514
func TestNewOAuthConfigUsesAuthOverrides(t *testing.T) {
615
t.Setenv("KERNEL_AUTH_BASE_URL", "https://auth.dev.onkernel.com/")
@@ -42,6 +51,129 @@ func TestTokenRefreshConfigPrefersStoredValues(t *testing.T) {
4251
}
4352
}
4453

54+
func TestOAuthCodeExchangeUsesAuthoritativeProjectScope(t *testing.T) {
55+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
56+
if err := r.ParseForm(); err != nil {
57+
t.Fatalf("ParseForm() error = %v", err)
58+
}
59+
if got, want := r.Form.Get("org_id"), "org_from_state"; got != want {
60+
t.Fatalf("org_id = %q, want %q", got, want)
61+
}
62+
w.Header().Set("Content-Type", "application/json")
63+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
64+
"access_token": "access-token",
65+
"refresh_token": "refresh-token",
66+
"token_type": "Bearer",
67+
"expires_in": 3600,
68+
"org_id": "org_authoritative",
69+
"access_scope": "project",
70+
"project_id": "proj_1",
71+
})
72+
}))
73+
defer server.Close()
74+
75+
config := &OAuthConfig{
76+
Config: &oauth2.Config{
77+
ClientID: "client-id",
78+
RedirectURL: "http://localhost/callback",
79+
Endpoint: oauth2.Endpoint{
80+
TokenURL: server.URL,
81+
AuthStyle: oauth2.AuthStyleInParams,
82+
},
83+
},
84+
Verifier: "verifier",
85+
AuthBaseURL: server.URL,
86+
OAuthClientID: "client-id",
87+
}
88+
89+
tokens, err := config.exchangeCodeForTokens(
90+
context.Background(),
91+
"code",
92+
"org_from_state",
93+
"organization",
94+
"",
95+
)
96+
if err != nil {
97+
t.Fatalf("exchangeCodeForTokens() error = %v", err)
98+
}
99+
if got, want := tokens.OrgID, "org_authoritative"; got != want {
100+
t.Fatalf("OrgID = %q, want %q", got, want)
101+
}
102+
if got, want := tokens.AccessScope, "project"; got != want {
103+
t.Fatalf("AccessScope = %q, want %q", got, want)
104+
}
105+
if got, want := tokens.ProjectID, "proj_1"; got != want {
106+
t.Fatalf("ProjectID = %q, want %q", got, want)
107+
}
108+
}
109+
110+
func TestRefreshTokensPreservesProjectScope(t *testing.T) {
111+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112+
if err := r.ParseForm(); err != nil {
113+
t.Fatalf("ParseForm() error = %v", err)
114+
}
115+
if got, want := r.Form.Get("refresh_token"), "refresh-old"; got != want {
116+
t.Fatalf("refresh_token = %q, want %q", got, want)
117+
}
118+
w.Header().Set("Content-Type", "application/json")
119+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
120+
"access_token": "access-new",
121+
"refresh_token": "refresh-new",
122+
"token_type": "Bearer",
123+
"expires_in": 3600,
124+
"org_id": "org_1",
125+
"access_scope": "project",
126+
"project_id": "proj_1",
127+
})
128+
}))
129+
defer server.Close()
130+
131+
tokens, err := RefreshTokens(context.Background(), &TokenStorage{
132+
RefreshToken: "refresh-old",
133+
ExpiresAt: time.Now().Add(-time.Hour),
134+
OrgID: "org_1",
135+
AccessScope: "project",
136+
ProjectID: "proj_1",
137+
AuthBaseURL: server.URL,
138+
OAuthClientID: "client-id",
139+
})
140+
if err != nil {
141+
t.Fatalf("RefreshTokens() error = %v", err)
142+
}
143+
if tokens.AccessScope != "project" || tokens.ProjectID != "proj_1" {
144+
t.Fatalf("RefreshTokens() scope = %q project = %q", tokens.AccessScope, tokens.ProjectID)
145+
}
146+
}
147+
148+
func TestRefreshLegacyTokensRemainOrganizationWide(t *testing.T) {
149+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
150+
w.Header().Set("Content-Type", "application/json")
151+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
152+
"access_token": "access-new",
153+
"refresh_token": "refresh-new",
154+
"token_type": "Bearer",
155+
"expires_in": 3600,
156+
})
157+
}))
158+
defer server.Close()
159+
160+
tokens, err := RefreshTokens(context.Background(), &TokenStorage{
161+
RefreshToken: "refresh-old",
162+
OrgID: "org_legacy",
163+
AuthBaseURL: server.URL,
164+
OAuthClientID: "client-id",
165+
})
166+
if err != nil {
167+
t.Fatalf("RefreshTokens() error = %v", err)
168+
}
169+
if got, want := tokens.AccessScope, "organization"; got != want {
170+
t.Fatalf("AccessScope = %q, want %q", got, want)
171+
}
172+
if tokens.ProjectID != "" {
173+
t.Fatalf("ProjectID = %q, want empty", tokens.ProjectID)
174+
}
175+
}
176+
45177
func TestLegacyTokenRefreshConfigUsesProdDefaults(t *testing.T) {
46178
t.Setenv("KERNEL_AUTH_BASE_URL", "https://auth.dev.onkernel.com")
47179
t.Setenv("KERNEL_OAUTH_CLIENT_ID", "staging-client-id")

‎pkg/auth/storage.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ type TokenStorage struct {
2121
RefreshToken string `json:"refresh_token"`
2222
ExpiresAt time.Time `json:"expires_at"`
2323
OrgID string `json:"org_id"`
24+
AccessScope string `json:"access_scope,omitempty"`
25+
ProjectID string `json:"project_id,omitempty"`
2426
AuthBaseURL string `json:"auth_base_url,omitempty"`
2527
OAuthClientID string `json:"oauth_client_id,omitempty"`
2628
}

0 commit comments

Comments
 (0)