Skip to content

Commit 0745e30

Browse files
authored
feat(auth): login code exchange instead of a token in the callback URL (#3630)
1 parent 580a801 commit 0745e30

61 files changed

Lines changed: 5899 additions & 96 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎app/cli/cmd/auth_login.go‎

Lines changed: 104 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,55 @@ import (
3333
jwt "github.com/golang-jwt/jwt/v5"
3434
"github.com/spf13/cobra"
3535
"github.com/spf13/viper"
36+
"golang.org/x/oauth2"
3637
)
3738

3839
type app struct {
3940
serverChan chan error
41+
callback *loginCallback
42+
}
43+
44+
// loginCallback turns the callback of a browser login into the session token
45+
type loginCallback struct {
46+
callbackURL string
47+
// codeVerifier is set when the login uses the code exchange
48+
codeVerifier string
49+
// whether the control plane still allows the token in the callback URL
50+
legacyAllowed bool
51+
exchange func(ctx context.Context, handoff, codeVerifier, callbackURL string) (string, error)
52+
warn func(msg string)
53+
}
54+
55+
var errNoCallbackCredentials = errors.New("the login callback did not carry a login code")
56+
57+
// token returns the session token for the callback request
58+
func (c *loginCallback) token(r *http.Request) (string, error) {
59+
q := r.URL.Query()
60+
if handoff := q.Get(oauth.QueryParamHandoff); handoff != "" && c.codeVerifier != "" {
61+
token, err := c.exchange(r.Context(), handoff, c.codeVerifier, c.callbackURL)
62+
if err != nil {
63+
return "", fmt.Errorf("exchanging the login code: %w", err)
64+
}
65+
return token, nil
66+
}
67+
68+
token := q.Get(oauth.QueryParamLegacyToken)
69+
if token == "" {
70+
return "", errNoCallbackCredentials
71+
}
72+
73+
// An older control plane, the token in the callback URL is the only method it has
74+
if c.codeVerifier == "" {
75+
return token, nil
76+
}
77+
78+
// The control plane supports the exchange, but the login page did not forward the code challenge
79+
if !c.legacyAllowed {
80+
return "", errors.New("the login returned the session token in the callback URL, which the control plane does not allow, refusing it")
81+
}
82+
83+
c.warn("the login returned the session token in the callback URL instead of a login code. This method is deprecated, make sure the login page forwards the code challenge to the control plane")
84+
return token, nil
4085
}
4186

4287
func newAuthLoginCmd() *cobra.Command {
@@ -61,12 +106,12 @@ func interactiveAuth(forceHeadless bool) error {
61106
return err
62107
}
63108
// Get the auth login from the control plane directly
64-
authURL, err := retrieveLoginURL()
109+
info, err := retrieveLoginInfo()
65110
if err != nil {
66111
return err
67112
}
68113

69-
serverLoginURL, err := url.Parse(authURL)
114+
serverLoginURL, err := url.Parse(info.GetLoginUrl())
70115
if err != nil {
71116
return err
72117
}
@@ -80,6 +125,18 @@ func interactiveAuth(forceHeadless bool) error {
80125
return headlessAuth(serverLoginURL)
81126
}
82127

128+
a.callback = &loginCallback{
129+
callbackURL: callbackURL.String(),
130+
legacyAllowed: info.GetLoginLegacyCallbackAllowed(),
131+
exchange: exchangeHandoff,
132+
warn: func(msg string) { logger.Warn().Msg(msg) },
133+
}
134+
135+
// Ask for a login code instead of the token when the control plane supports it
136+
if info.GetLoginCodeExchangeSupported() {
137+
a.callback.codeVerifier = addCodeChallenge(serverLoginURL)
138+
}
139+
83140
err = openbrowser(serverLoginURL.String())
84141
if err != nil {
85142
logger.Debug().Err(err).Msg("falling back to manual login")
@@ -109,11 +166,44 @@ func interactiveAuth(forceHeadless bool) error {
109166
return nil
110167
}
111168

112-
func headlessAuth(loginURL *url.URL) error {
113-
// Remove cli-callback query parameter to indicate the server to show it inline
169+
// addCodeChallenge adds a S256 code challenge to the login URL and returns its verifier
170+
func addCodeChallenge(loginURL *url.URL) string {
171+
verifier := oauth2.GenerateVerifier()
172+
114173
q := loginURL.Query()
115-
q.Set("callback", "")
174+
q.Set(oauth.QueryParamCodeChallenge, oauth2.S256ChallengeFromVerifier(verifier))
175+
q.Set(oauth.QueryParamCodeChallengeMethod, oauth.CodeChallengeMethodS256)
116176
loginURL.RawQuery = q.Encode()
177+
178+
return verifier
179+
}
180+
181+
// exchangeHandoff trades the login code for the session token
182+
func exchangeHandoff(ctx context.Context, handoff, codeVerifier, callbackURL string) (string, error) {
183+
resp, err := pb.NewAuthServiceClient(ActionOpts.CPConnection).ExchangeHandoff(ctx, &pb.AuthServiceExchangeHandoffRequest{
184+
Handoff: handoff,
185+
CodeVerifier: codeVerifier,
186+
Callback: callbackURL,
187+
})
188+
if err != nil {
189+
return "", err
190+
}
191+
192+
return resp.GetToken(), nil
193+
}
194+
195+
// toHeadlessLoginURL removes the callback, so the server shows the token inline. The code
196+
// challenge goes too: the headless page has no code to exchange, and a challenge needs a callback
197+
func toHeadlessLoginURL(loginURL *url.URL) {
198+
q := loginURL.Query()
199+
q.Set(oauth.QueryParamCallback, "")
200+
q.Del(oauth.QueryParamCodeChallenge)
201+
q.Del(oauth.QueryParamCodeChallengeMethod)
202+
loginURL.RawQuery = q.Encode()
203+
}
204+
205+
func headlessAuth(loginURL *url.URL) error {
206+
toHeadlessLoginURL(loginURL)
117207
fmt.Printf("To authenticate, click on the following link and paste the result back here\n\n %s\n\n", loginURL.String())
118208

119209
fmt.Print("Enter Token: ")
@@ -145,18 +235,19 @@ func saveAuthToken(token string) error {
145235
}
146236

147237
func (a *app) handleCallback(w http.ResponseWriter, r *http.Request) {
148-
token := r.URL.Query().Get("t")
149-
238+
var err error
150239
defer func() {
151-
a.serverChan <- nil
240+
a.serverChan <- err
152241
}()
153242

154-
if token == "" {
243+
token, err := a.callback.token(r)
244+
if err != nil {
155245
w.WriteHeader(http.StatusExpectationFailed)
246+
fmt.Fprintln(w, "login failed, please check the terminal")
156247
return
157248
}
158249

159-
if err := saveAuthToken(token); err != nil {
250+
if err = saveAuthToken(token); err != nil {
160251
w.WriteHeader(http.StatusInternalServerError)
161252
return
162253
}
@@ -197,14 +288,10 @@ func isRemoteSession() bool {
197288
return false
198289
}
199290

200-
// Retrieve loginURL from the control plane
201-
func retrieveLoginURL() (string, error) {
291+
// Retrieve the login URL and the login capabilities from the control plane
292+
func retrieveLoginInfo() (*pb.InfozResponse, error) {
202293
client := pb.NewStatusServiceClient(ActionOpts.CPConnection)
203-
resp, err := client.Infoz(context.Background(), &pb.InfozRequest{})
204-
if err != nil {
205-
return "", err
206-
}
207-
return resp.LoginUrl, nil
294+
return client.Infoz(context.Background(), &pb.InfozRequest{})
208295
}
209296

210297
// Create a local HTTP listener with a random available port

‎app/cli/cmd/auth_login_test.go‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package cmd
17+
18+
import (
19+
"context"
20+
"errors"
21+
"net/http"
22+
"net/http/httptest"
23+
"net/url"
24+
"testing"
25+
26+
"github.com/chainloop-dev/chainloop/internal/oauth"
27+
"github.com/stretchr/testify/assert"
28+
"github.com/stretchr/testify/require"
29+
"golang.org/x/oauth2"
30+
)
31+
32+
func TestAddCodeChallenge(t *testing.T) {
33+
loginURL, err := url.Parse("https://cp.example/auth/login?callback=http%3A%2F%2Flocalhost%3A1234%2Fauth%2Fcallback")
34+
require.NoError(t, err)
35+
36+
verifier := addCodeChallenge(loginURL)
37+
38+
q := loginURL.Query()
39+
assert.Equal(t, oauth2.S256ChallengeFromVerifier(verifier), q.Get(oauth.QueryParamCodeChallenge))
40+
assert.Equal(t, oauth.CodeChallengeMethodS256, q.Get(oauth.QueryParamCodeChallengeMethod))
41+
// Existing parameters are kept, and the verifier never goes in the URL
42+
assert.Equal(t, "http://localhost:1234/auth/callback", q.Get(oauth.QueryParamCallback))
43+
assert.NotContains(t, loginURL.String(), verifier)
44+
}
45+
46+
// The browser fallback switches to headless after the challenge was added
47+
func TestToHeadlessLoginURL(t *testing.T) {
48+
loginURL, err := url.Parse("https://cp.example/auth/login?callback=http%3A%2F%2Flocalhost%3A1234%2Fauth%2Fcallback&long-lived=true")
49+
require.NoError(t, err)
50+
addCodeChallenge(loginURL)
51+
52+
toHeadlessLoginURL(loginURL)
53+
54+
q := loginURL.Query()
55+
assert.True(t, q.Has(oauth.QueryParamCallback))
56+
assert.Empty(t, q.Get(oauth.QueryParamCallback))
57+
assert.False(t, q.Has(oauth.QueryParamCodeChallenge))
58+
assert.False(t, q.Has(oauth.QueryParamCodeChallengeMethod))
59+
assert.Equal(t, "true", q.Get(oauth.QueryParamLongLived))
60+
}
61+
62+
func TestLoginCallbackToken(t *testing.T) {
63+
const (
64+
callbackURL = "http://localhost:1234/auth/callback"
65+
handoffQuery = "handoff=the-code"
66+
legacyQuery = "t=legacy-token"
67+
testVerifier = "verifier"
68+
)
69+
70+
testCases := []struct {
71+
name string
72+
// query of the callback request
73+
query string
74+
codeVerifier string
75+
legacyAllowed bool
76+
exchangeErr error
77+
wantToken string
78+
wantErr bool
79+
wantExchange bool
80+
wantWarning bool
81+
}{
82+
{
83+
name: "code exchange",
84+
query: handoffQuery,
85+
codeVerifier: testVerifier,
86+
wantToken: "exchanged-token",
87+
wantExchange: true,
88+
},
89+
{
90+
name: "failed exchange",
91+
query: handoffQuery,
92+
codeVerifier: testVerifier,
93+
exchangeErr: errors.New("invalid login code"),
94+
wantExchange: true,
95+
wantErr: true,
96+
},
97+
{
98+
name: "legacy callback while allowed",
99+
query: legacyQuery,
100+
codeVerifier: testVerifier,
101+
legacyAllowed: true,
102+
wantToken: "legacy-token",
103+
wantWarning: true,
104+
},
105+
{
106+
name: "legacy callback while denied",
107+
query: legacyQuery,
108+
codeVerifier: testVerifier,
109+
legacyAllowed: false,
110+
wantErr: true,
111+
},
112+
{
113+
name: "older control plane",
114+
query: legacyQuery,
115+
wantToken: "legacy-token",
116+
},
117+
{
118+
name: "handoff from an older control plane is not expected",
119+
query: handoffQuery,
120+
wantErr: true,
121+
},
122+
{
123+
name: "no credentials",
124+
query: "",
125+
codeVerifier: testVerifier,
126+
wantErr: true,
127+
},
128+
}
129+
130+
for _, tc := range testCases {
131+
t.Run(tc.name, func(t *testing.T) {
132+
var exchanged, warned bool
133+
c := &loginCallback{
134+
callbackURL: callbackURL,
135+
codeVerifier: tc.codeVerifier,
136+
legacyAllowed: tc.legacyAllowed,
137+
exchange: func(_ context.Context, handoff, codeVerifier, cb string) (string, error) {
138+
exchanged = true
139+
assert.Equal(t, "the-code", handoff)
140+
assert.Equal(t, tc.codeVerifier, codeVerifier)
141+
assert.Equal(t, callbackURL, cb)
142+
return "exchanged-token", tc.exchangeErr
143+
},
144+
warn: func(string) { warned = true },
145+
}
146+
147+
r := httptest.NewRequest(http.MethodGet, "/auth/callback?"+tc.query, nil)
148+
token, err := c.token(r)
149+
150+
assert.Equal(t, tc.wantExchange, exchanged)
151+
assert.Equal(t, tc.wantWarning, warned)
152+
if tc.wantErr {
153+
assert.Error(t, err)
154+
assert.Empty(t, token)
155+
return
156+
}
157+
158+
require.NoError(t, err)
159+
assert.Equal(t, tc.wantToken, token)
160+
})
161+
}
162+
}

0 commit comments

Comments
 (0)