From a21be2f78027ac1d01a0ecf20322d62fce72ad0a Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:52:22 +0000 Subject: [PATCH 1/5] Persist OAuth project scope --- README.md | 3 +- cmd/auth.go | 5 ++ pkg/auth/oauth.go | 67 ++++++++++++++++++--- pkg/auth/oauth_test.go | 134 ++++++++++++++++++++++++++++++++++++++++- pkg/auth/storage.go | 2 + 5 files changed, 200 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c54f458c..b70960e0 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ The easiest way to authenticate is using OAuth: kernel login ``` -This opens your browser to complete the authentication flow. Your credentials are securely stored and automatically refreshed. +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. ### API Key @@ -103,6 +103,7 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). - `--version`, `-v` - Print the CLI version - `--no-color` - Disable color output - `--log-level ` - Set log level (trace, debug, info, warn, error, fatal, print) +- `--project ` - Select a project for requests made with an organization-wide credential. Project-scoped OAuth tokens cannot switch projects. ## JSON Output diff --git a/cmd/auth.go b/cmd/auth.go index 38d63725..0b462e04 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -108,6 +108,11 @@ func runAuth(cmd *cobra.Command, args []string) error { pterm.Info.Printf("API URL: %s\n", util.GetBaseURL()) pterm.Info.Printf("Auth URL: %s\n", tokenAuthBaseURLForDisplay(tokens)) pterm.Info.Printf("OAuth client ID: %s\n", maskClientID(tokenOAuthClientIDForDisplay(tokens))) + if tokens.AccessScope == "project" && tokens.ProjectID != "" { + pterm.Info.Printf("Access scope: project %s\n", tokens.ProjectID) + } else { + pterm.Info.Println("Access scope: organization-wide") + } // Extract info from JWT token if claims, err := parseJWT(tokens.AccessToken); err == nil && claims != nil { diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go index 42c4a83d..082f2c15 100644 --- a/pkg/auth/oauth.go +++ b/pkg/auth/oauth.go @@ -67,12 +67,16 @@ type TokenResponse struct { ExpiresIn int `json:"expires_in"` TokenType string `json:"token_type"` OrgID string `json:"org_id"` + AccessScope string `json:"access_scope"` + ProjectID string `json:"project_id"` } // AuthResult represents the result data passed through the callback channel type AuthResult struct { - Code string `json:"code"` - OrgID string `json:"org_id,omitempty"` + Code string `json:"code"` + OrgID string `json:"org_id,omitempty"` + AccessScope string `json:"access_scope,omitempty"` + ProjectID string `json:"project_id,omitempty"` } // CurrentAuthBaseURL returns the OAuth server base URL for new login flows. @@ -210,7 +214,7 @@ func (oc *OAuthConfig) StartOAuthFlow(ctx context.Context) (*TokenStorage, error mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { // Extract and decode state parameter to get CSRF token and org_id encodedState := r.URL.Query().Get("state") - var csrfToken, orgID string + var csrfToken, orgID, accessScope, projectID string if encodedState != "" { // Try to decode the state parameter @@ -219,6 +223,8 @@ func (oc *OAuthConfig) StartOAuthFlow(ctx context.Context) (*TokenStorage, error if json.Unmarshal(decodedBytes, &stateData) == nil { csrfToken = stateData["csrf"] orgID = stateData["org_id"] + accessScope = stateData["access_scope"] + projectID = stateData["project_id"] } } @@ -258,8 +264,10 @@ func (oc *OAuthConfig) StartOAuthFlow(ctx context.Context) (*TokenStorage, error // Pass both code and org_id to the channel using JSON encoding result := AuthResult{ - Code: code, - OrgID: orgID, + Code: code, + OrgID: orgID, + AccessScope: accessScope, + ProjectID: projectID, } resultJSON, err := json.Marshal(result) if err != nil { @@ -279,7 +287,7 @@ func (oc *OAuthConfig) StartOAuthFlow(ctx context.Context) (*TokenStorage, error }() // Wait for callback or timeout - var authCode, orgID string + var authCode, orgID, accessScope, projectID string select { case resultJSON := <-codeChan: // Success - shutdown server @@ -291,6 +299,8 @@ func (oc *OAuthConfig) StartOAuthFlow(ctx context.Context) (*TokenStorage, error } authCode = result.Code orgID = result.OrgID + accessScope = result.AccessScope + projectID = result.ProjectID case err := <-errChan: server.Shutdown(context.Background()) return nil, err @@ -303,11 +313,11 @@ func (oc *OAuthConfig) StartOAuthFlow(ctx context.Context) (*TokenStorage, error } // Exchange authorization code for tokens - return oc.exchangeCodeForTokens(ctx, authCode, orgID) + return oc.exchangeCodeForTokens(ctx, authCode, orgID, accessScope, projectID) } // exchangeCodeForTokens exchanges the authorization code for access and refresh tokens -func (oc *OAuthConfig) exchangeCodeForTokens(ctx context.Context, code, orgID string) (*TokenStorage, error) { +func (oc *OAuthConfig) exchangeCodeForTokens(ctx context.Context, code, orgID, accessScope, projectID string) (*TokenStorage, error) { // Use PKCE verifier in token exchange, and include org_id if available var opts []oauth2.AuthCodeOption opts = append(opts, oauth2.SetAuthURLParam("code_verifier", oc.Verifier)) @@ -323,11 +333,29 @@ func (oc *OAuthConfig) exchangeCodeForTokens(ctx context.Context, code, orgID st return nil, fmt.Errorf("failed to exchange code for token: %w", err) } + if value, ok := token.Extra("org_id").(string); ok && value != "" { + orgID = value + } + if value, ok := token.Extra("access_scope").(string); ok && value != "" { + accessScope = value + } + if value, ok := token.Extra("project_id").(string); ok { + projectID = value + } + if accessScope == "" { + accessScope = "organization" + } + if accessScope == "organization" { + projectID = "" + } + return &TokenStorage{ AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, ExpiresAt: token.Expiry, OrgID: orgID, + AccessScope: accessScope, + ProjectID: projectID, AuthBaseURL: oc.AuthBaseURL, OAuthClientID: oc.OAuthClientID, }, nil @@ -388,11 +416,32 @@ func RefreshTokens(ctx context.Context, tokens *TokenStorage) (*TokenStorage, er // Add extra fields newToken = newToken.WithExtra(tokenResponse) + orgID := tokens.OrgID + if value, ok := tokenResponse["org_id"].(string); ok && value != "" { + orgID = value + } + accessScope := tokens.AccessScope + if value, ok := tokenResponse["access_scope"].(string); ok && value != "" { + accessScope = value + } + if accessScope == "" { + accessScope = "organization" + } + projectID := tokens.ProjectID + if value, ok := tokenResponse["project_id"].(string); ok { + projectID = value + } + if accessScope == "organization" { + projectID = "" + } + return &TokenStorage{ AccessToken: newToken.AccessToken, RefreshToken: newToken.RefreshToken, ExpiresAt: newToken.Expiry, - OrgID: tokens.OrgID, + OrgID: orgID, + AccessScope: accessScope, + ProjectID: projectID, AuthBaseURL: tokenAuthBaseURL(tokens), OAuthClientID: tokenOAuthClientID(tokens), }, nil diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go index 059034e0..820a314e 100644 --- a/pkg/auth/oauth_test.go +++ b/pkg/auth/oauth_test.go @@ -1,6 +1,15 @@ package auth -import "testing" +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "golang.org/x/oauth2" +) func TestNewOAuthConfigUsesAuthOverrides(t *testing.T) { t.Setenv("KERNEL_AUTH_BASE_URL", "https://auth.dev.onkernel.com/") @@ -42,6 +51,129 @@ func TestTokenRefreshConfigPrefersStoredValues(t *testing.T) { } } +func TestOAuthCodeExchangeUsesAuthoritativeProjectScope(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm() error = %v", err) + } + if got, want := r.Form.Get("org_id"), "org_from_state"; got != want { + t.Fatalf("org_id = %q, want %q", got, want) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "access-token", + "refresh_token": "refresh-token", + "token_type": "Bearer", + "expires_in": 3600, + "org_id": "org_authoritative", + "access_scope": "project", + "project_id": "proj_1", + }) + })) + defer server.Close() + + config := &OAuthConfig{ + Config: &oauth2.Config{ + ClientID: "client-id", + RedirectURL: "http://localhost/callback", + Endpoint: oauth2.Endpoint{ + TokenURL: server.URL, + AuthStyle: oauth2.AuthStyleInParams, + }, + }, + Verifier: "verifier", + AuthBaseURL: server.URL, + OAuthClientID: "client-id", + } + + tokens, err := config.exchangeCodeForTokens( + context.Background(), + "code", + "org_from_state", + "organization", + "", + ) + if err != nil { + t.Fatalf("exchangeCodeForTokens() error = %v", err) + } + if got, want := tokens.OrgID, "org_authoritative"; got != want { + t.Fatalf("OrgID = %q, want %q", got, want) + } + if got, want := tokens.AccessScope, "project"; got != want { + t.Fatalf("AccessScope = %q, want %q", got, want) + } + if got, want := tokens.ProjectID, "proj_1"; got != want { + t.Fatalf("ProjectID = %q, want %q", got, want) + } +} + +func TestRefreshTokensPreservesProjectScope(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm() error = %v", err) + } + if got, want := r.Form.Get("refresh_token"), "refresh-old"; got != want { + t.Fatalf("refresh_token = %q, want %q", got, want) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "access-new", + "refresh_token": "refresh-new", + "token_type": "Bearer", + "expires_in": 3600, + "org_id": "org_1", + "access_scope": "project", + "project_id": "proj_1", + }) + })) + defer server.Close() + + tokens, err := RefreshTokens(context.Background(), &TokenStorage{ + RefreshToken: "refresh-old", + ExpiresAt: time.Now().Add(-time.Hour), + OrgID: "org_1", + AccessScope: "project", + ProjectID: "proj_1", + AuthBaseURL: server.URL, + OAuthClientID: "client-id", + }) + if err != nil { + t.Fatalf("RefreshTokens() error = %v", err) + } + if tokens.AccessScope != "project" || tokens.ProjectID != "proj_1" { + t.Fatalf("RefreshTokens() scope = %q project = %q", tokens.AccessScope, tokens.ProjectID) + } +} + +func TestRefreshLegacyTokensRemainOrganizationWide(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "access-new", + "refresh_token": "refresh-new", + "token_type": "Bearer", + "expires_in": 3600, + }) + })) + defer server.Close() + + tokens, err := RefreshTokens(context.Background(), &TokenStorage{ + RefreshToken: "refresh-old", + OrgID: "org_legacy", + AuthBaseURL: server.URL, + OAuthClientID: "client-id", + }) + if err != nil { + t.Fatalf("RefreshTokens() error = %v", err) + } + if got, want := tokens.AccessScope, "organization"; got != want { + t.Fatalf("AccessScope = %q, want %q", got, want) + } + if tokens.ProjectID != "" { + t.Fatalf("ProjectID = %q, want empty", tokens.ProjectID) + } +} + func TestLegacyTokenRefreshConfigUsesProdDefaults(t *testing.T) { t.Setenv("KERNEL_AUTH_BASE_URL", "https://auth.dev.onkernel.com") t.Setenv("KERNEL_OAUTH_CLIENT_ID", "staging-client-id") diff --git a/pkg/auth/storage.go b/pkg/auth/storage.go index 95195645..0a79fc8a 100644 --- a/pkg/auth/storage.go +++ b/pkg/auth/storage.go @@ -21,6 +21,8 @@ type TokenStorage struct { RefreshToken string `json:"refresh_token"` ExpiresAt time.Time `json:"expires_at"` OrgID string `json:"org_id"` + AccessScope string `json:"access_scope,omitempty"` + ProjectID string `json:"project_id,omitempty"` AuthBaseURL string `json:"auth_base_url,omitempty"` OAuthClientID string `json:"oauth_client_id,omitempty"` } From 9e072081c5fe9ee240fd776647d56b1ce0abe3a1 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:35:44 +0000 Subject: [PATCH 2/5] Show status for plain-text API errors --- pkg/util/errors.go | 3 ++- pkg/util/errors_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 pkg/util/errors_test.go diff --git a/pkg/util/errors.go b/pkg/util/errors.go index 76fe233d..a85ade86 100644 --- a/pkg/util/errors.go +++ b/pkg/util/errors.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "strings" "github.com/kernel/kernel-go-sdk" ) @@ -29,7 +30,7 @@ func (e CleanedUpSdkError) Error() string { // try response body as text body, err := io.ReadAll(kerror.Response.Body) if err == nil && len(body) > 0 { - return string(body) + return fmt.Sprintf("%d: %s", kerror.StatusCode, strings.TrimSpace(string(body))) } } } diff --git a/pkg/util/errors_test.go b/pkg/util/errors_test.go new file mode 100644 index 00000000..b1e1e350 --- /dev/null +++ b/pkg/util/errors_test.go @@ -0,0 +1,24 @@ +package util + +import ( + "io" + "net/http" + "strings" + "testing" + + kernel "github.com/kernel/kernel-go-sdk" +) + +func TestCleanedUpSDKErrorIncludesStatusForPlainTextResponses(t *testing.T) { + err := CleanedUpSdkError{Err: &kernel.Error{ + StatusCode: http.StatusForbidden, + Response: &http.Response{ + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader("Credential is scoped to a different project\n")), + }, + }} + + if got, want := err.Error(), "403: Credential is scoped to a different project"; got != want { + t.Fatalf("Error() = %q, want %q", got, want) + } +} From ede8f54e3b4afa09faac67f01f892e71d9aaf459 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:54:25 +0000 Subject: [PATCH 3/5] Expect OAuth token scope wording --- pkg/util/errors_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/util/errors_test.go b/pkg/util/errors_test.go index b1e1e350..4599e4f2 100644 --- a/pkg/util/errors_test.go +++ b/pkg/util/errors_test.go @@ -14,11 +14,11 @@ func TestCleanedUpSDKErrorIncludesStatusForPlainTextResponses(t *testing.T) { StatusCode: http.StatusForbidden, Response: &http.Response{ StatusCode: http.StatusForbidden, - Body: io.NopCloser(strings.NewReader("Credential is scoped to a different project\n")), + Body: io.NopCloser(strings.NewReader("OAuth token is scoped to a different project\n")), }, }} - if got, want := err.Error(), "403: Credential is scoped to a different project"; got != want { + if got, want := err.Error(), "403: OAuth token is scoped to a different project"; got != want { t.Fatalf("Error() = %q, want %q", got, want) } } From 9cd0c368a40828a6bffd1f7e1ec333b3f79234c3 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:03:44 +0000 Subject: [PATCH 4/5] Revert "Expect OAuth token scope wording" This reverts commit ede8f54e3b4afa09faac67f01f892e71d9aaf459. --- pkg/util/errors_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/util/errors_test.go b/pkg/util/errors_test.go index 4599e4f2..b1e1e350 100644 --- a/pkg/util/errors_test.go +++ b/pkg/util/errors_test.go @@ -14,11 +14,11 @@ func TestCleanedUpSDKErrorIncludesStatusForPlainTextResponses(t *testing.T) { StatusCode: http.StatusForbidden, Response: &http.Response{ StatusCode: http.StatusForbidden, - Body: io.NopCloser(strings.NewReader("OAuth token is scoped to a different project\n")), + Body: io.NopCloser(strings.NewReader("Credential is scoped to a different project\n")), }, }} - if got, want := err.Error(), "403: OAuth token is scoped to a different project"; got != want { + if got, want := err.Error(), "403: Credential is scoped to a different project"; got != want { t.Fatalf("Error() = %q, want %q", got, want) } } From d76696791794b00f5c0fbee038192d9093ae8b96 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:03:44 +0000 Subject: [PATCH 5/5] Revert "Show status for plain-text API errors" This reverts commit 9e072081c5fe9ee240fd776647d56b1ce0abe3a1. --- pkg/util/errors.go | 3 +-- pkg/util/errors_test.go | 24 ------------------------ 2 files changed, 1 insertion(+), 26 deletions(-) delete mode 100644 pkg/util/errors_test.go diff --git a/pkg/util/errors.go b/pkg/util/errors.go index a85ade86..76fe233d 100644 --- a/pkg/util/errors.go +++ b/pkg/util/errors.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "io" - "strings" "github.com/kernel/kernel-go-sdk" ) @@ -30,7 +29,7 @@ func (e CleanedUpSdkError) Error() string { // try response body as text body, err := io.ReadAll(kerror.Response.Body) if err == nil && len(body) > 0 { - return fmt.Sprintf("%d: %s", kerror.StatusCode, strings.TrimSpace(string(body))) + return string(body) } } } diff --git a/pkg/util/errors_test.go b/pkg/util/errors_test.go deleted file mode 100644 index b1e1e350..00000000 --- a/pkg/util/errors_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package util - -import ( - "io" - "net/http" - "strings" - "testing" - - kernel "github.com/kernel/kernel-go-sdk" -) - -func TestCleanedUpSDKErrorIncludesStatusForPlainTextResponses(t *testing.T) { - err := CleanedUpSdkError{Err: &kernel.Error{ - StatusCode: http.StatusForbidden, - Response: &http.Response{ - StatusCode: http.StatusForbidden, - Body: io.NopCloser(strings.NewReader("Credential is scoped to a different project\n")), - }, - }} - - if got, want := err.Error(), "403: Credential is scoped to a different project"; got != want { - t.Fatalf("Error() = %q, want %q", got, want) - } -}