Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions util/auth-handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,16 @@ import (
"strings"
"time"

"cloud.google.com/go/auth"
"golang.org/x/oauth2/authhandler"
)

// 3LO authorization handler. Determines what algorithm to use
// to get the authorization code.
// Get3LOAuthHandler returns a 3LO authorization handler for cloud.google.com/go/auth.
// Determines what algorithm to use to get the authorization code.
//
// Note that the "state" parameter is used to prevent CSRF attacks.
func Get3LOAuthorizationHandler(state string, consentSettings ConsentPageSettings,
authCodeServer *AuthorizationCodeServer) authhandler.AuthorizationHandler {
func Get3LOAuthHandler(state string, consentSettings ConsentPageSettings,
authCodeServer *AuthorizationCodeServer) auth.AuthorizationHandler {
return func(authCodeURL string) (string, string, error) {
decodedValue, _ := url.ParseQuery(authCodeURL)
redirectURL := decodedValue.Get("redirect_uri")
Expand All @@ -43,6 +44,18 @@ func Get3LOAuthorizationHandler(state string, consentSettings ConsentPageSetting
}
}

// Get3LOAuthorizationHandler returns a 3LO authorization handler for
// golang.org/x/oauth2/authhandler. Determines what algorithm to use to get
// the authorization code.
//
// Note that the "state" parameter is used to prevent CSRF attacks.
//
// Deprecated: Use Get3LOAuthHandler instead.
func Get3LOAuthorizationHandler(state string, consentSettings ConsentPageSettings,
authCodeServer *AuthorizationCodeServer) authhandler.AuthorizationHandler {
return authhandler.AuthorizationHandler(Get3LOAuthHandler(state, consentSettings, authCodeServer))
}

// authorization3LOOutOfBand prints the authorization URL on stdout
// and reads the authorization code from stdin.
//
Expand Down
206 changes: 206 additions & 0 deletions util/auth-handlers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
//
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package util

import (
"errors"
"os"
"testing"
"time"
)

type fakeAuthCodeServer struct {
listening bool
authCode AuthorizationCode
authErr error
waitedConsent bool
}

func (f *fakeAuthCodeServer) ListenAndServe(address string) (string, error) {
return address, nil
}

func (f *fakeAuthCodeServer) Close() {}

func (f *fakeAuthCodeServer) IsListeningAndServing() bool {
return f.listening
}

func (f *fakeAuthCodeServer) WaitForListeningAndServing(maxWaitTime time.Duration) (bool, error) {
return f.listening, nil
}

func (f *fakeAuthCodeServer) GetAuthenticationCode() (AuthorizationCode, error) {
return f.authCode, f.authErr
}

func (f *fakeAuthCodeServer) WaitForConsentPageToReturnControl() error {
f.waitedConsent = true
return nil
}

// silenceStdout redirects process-global os.Stdout to os.DevNull for the duration
// of a test. Tests calling this helper must not be marked t.Parallel().
func silenceStdout(t *testing.T) {
t.Helper()
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil {
t.Fatalf("os.OpenFile(%q) failed: %v", os.DevNull, err)
}
origStdout := os.Stdout
os.Stdout = devNull
t.Cleanup(func() {
os.Stdout = origStdout
devNull.Close()
})
}

func TestGet3LOAuthHandlerLoopback(t *testing.T) {
silenceStdout(t)
fakeSrv := &fakeAuthCodeServer{
listening: true,
authCode: AuthorizationCode{
Code: "loopback-code",
State: "loopback-state",
},
}
var srv AuthorizationCodeServer = fakeSrv
consentSettings := ConsentPageSettings{
DisableAutoOpenConsentPage: true,
InteractionTimeout: time.Second,
}

handler := Get3LOAuthHandler("unused-oob-state", consentSettings, &srv)
authURL := "https://accounts.google.com/o/oauth2/auth?client_id=test&redirect_uri=http%3A%2F%2Flocalhost%3A8080"

code, state, err := handler(authURL)
if err != nil {
t.Fatalf("Get3LOAuthHandler() unexpected error = %v", err)
}
if !fakeSrv.waitedConsent {
t.Error("expected WaitForConsentPageToReturnControl to be called")
}
if code != "loopback-code" || state != "loopback-state" {
t.Errorf("Get3LOAuthHandler() = (%q, %q), want (%q, %q)", code, state, "loopback-code", "loopback-state")
}
}

func TestGet3LOAuthHandlerLoopbackError(t *testing.T) {
silenceStdout(t)
wantErr := errors.New("consent denied")
fakeSrv := &fakeAuthCodeServer{
listening: true,
authErr: wantErr,
}
var srv AuthorizationCodeServer = fakeSrv
consentSettings := ConsentPageSettings{
DisableAutoOpenConsentPage: true,
InteractionTimeout: time.Second,
}

handler := Get3LOAuthHandler("state", consentSettings, &srv)
authURL := "https://accounts.google.com/o/oauth2/auth?client_id=test&redirect_uri=http%3A%2F%2Flocalhost%3A8080"

_, _, err := handler(authURL)
if !fakeSrv.waitedConsent {
t.Error("expected WaitForConsentPageToReturnControl to be called")
}
if !errors.Is(err, wantErr) {
t.Fatalf("Get3LOAuthHandler() error = %v, want %v", err, wantErr)
}
}

func TestGet3LOAuthHandlerLoopbackServerNotListening(t *testing.T) {
wantErr := errors.New("server failed to start")
fakeSrv := &fakeAuthCodeServer{
listening: false,
authErr: wantErr,
}
var srv AuthorizationCodeServer = fakeSrv
consentSettings := ConsentPageSettings{
DisableAutoOpenConsentPage: true,
InteractionTimeout: time.Second,
}

handler := Get3LOAuthHandler("state", consentSettings, &srv)
authURL := "https://accounts.google.com/o/oauth2/auth?client_id=test&redirect_uri=http%3A%2F%2Flocalhost%3A8080"

_, _, err := handler(authURL)
if fakeSrv.waitedConsent {
t.Error("did not expect WaitForConsentPageToReturnControl to be called when server is not listening")
}
if !errors.Is(err, wantErr) {
t.Fatalf("Get3LOAuthHandler() error = %v, want %v", err, wantErr)
}
}

// TestGet3LOAuthHandlerOutOfBand mutates process-global os.Stdin and os.Stdout; do not mark t.Parallel().
func TestGet3LOAuthHandlerOutOfBand(t *testing.T) {
silenceStdout(t)
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe() failed: %v", err)
}
origStdin := os.Stdin
os.Stdin = r
defer func() {
os.Stdin = origStdin
r.Close()
}()

if _, err := w.WriteString("oob-auth-code\n"); err != nil {
t.Fatalf("WriteString() failed: %v", err)
}
w.Close()

var srv AuthorizationCodeServer
handler := Get3LOAuthHandler("oob-state", ConsentPageSettings{}, &srv)
authURL := "https://accounts.google.com/o/oauth2/auth?client_id=test&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob"

code, state, err := handler(authURL)
if err != nil {
t.Fatalf("Get3LOAuthHandler() unexpected error = %v", err)
}
if code != "oob-auth-code" || state != "oob-state" {
t.Errorf("Get3LOAuthHandler() = (%q, %q), want (%q, %q)", code, state, "oob-auth-code", "oob-state")
}
}

func TestGet3LOAuthorizationHandlerDelegatesToGet3LOAuthHandler(t *testing.T) {
silenceStdout(t)
fakeSrv := &fakeAuthCodeServer{
listening: true,
authCode: AuthorizationCode{
Code: "legacy-loopback-code",
State: "legacy-loopback-state",
},
}
var srv AuthorizationCodeServer = fakeSrv
consentSettings := ConsentPageSettings{
DisableAutoOpenConsentPage: true,
InteractionTimeout: time.Second,
}

handler := Get3LOAuthorizationHandler("state", consentSettings, &srv)
authURL := "https://accounts.google.com/o/oauth2/auth?client_id=test&redirect_uri=http%3A%2F%2Flocalhost%3A8080"

code, state, err := handler(authURL)
if err != nil {
t.Fatalf("Get3LOAuthorizationHandler() unexpected error = %v", err)
}
if code != "legacy-loopback-code" || state != "legacy-loopback-state" {
t.Errorf("Get3LOAuthorizationHandler() = (%q, %q), want (%q, %q)", code, state, "legacy-loopback-code", "legacy-loopback-state")
}
}
11 changes: 10 additions & 1 deletion util/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ import (
"fmt"
"strings"

// Blank import for go mod tidy and vendor; will be imported directly in Phase 4.
_ "cloud.google.com/go/auth/oauth2adapt"
"golang.org/x/oauth2"
// Temporary import to convert settings.resolveAuthHandler() for google.CredentialsParams;
// will be removed in the next phase (Phase 4) when google.CredentialsParams is replaced
// with credentials.DetectOptions.
"golang.org/x/oauth2/authhandler"
"golang.org/x/oauth2/google"
)

Expand Down Expand Up @@ -94,7 +100,10 @@ func FindJSONCredentials(ctx context.Context, settings *Settings) (*google.Crede
var params google.CredentialsParams
params.Scopes = strings.Split(settings.Scope, " ")
params.State = "state"
params.AuthHandler = settings.AuthHandler
// Convert the resolved handler back to authhandler.AuthorizationHandler and
// use GeneratePKCEParams() while FindJSONCredentials still constructs
// google.CredentialsParams; Phase 4 replaces this with FindAuthCredentials.
params.AuthHandler = authhandler.AuthorizationHandler(settings.resolveAuthHandler())
params.Subject = settings.Email
params.PKCE = GeneratePKCEParams()
if settings.CredentialsJSON != "" {
Expand Down
20 changes: 17 additions & 3 deletions util/pkce.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,34 @@ import (
"crypto/sha256"
"encoding/base64"

"cloud.google.com/go/auth"
"github.com/google/uuid"
"golang.org/x/oauth2/authhandler"
)

// GeneratePKCEParams generates a unique PKCE challenge and verifier combination,
// GeneratePKCEOptions generates a unique PKCE challenge and verifier combination,
// using UUID, SHA256 encryption, and base64 URL encoding with no padding.
func GeneratePKCEParams() *authhandler.PKCEParams {
func GeneratePKCEOptions() *auth.PKCEOptions {
verifier := uuid.New().String()
sha := sha256.Sum256([]byte(verifier))
challenge := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(sha[:])

return &authhandler.PKCEParams{
return &auth.PKCEOptions{
Challenge: challenge,
ChallengeMethod: "S256",
Verifier: verifier,
}
}

// GeneratePKCEParams generates a unique PKCE challenge and verifier combination,
// using UUID, SHA256 encryption, and base64 URL encoding with no padding.
//
// Deprecated: Use GeneratePKCEOptions instead.
func GeneratePKCEParams() *authhandler.PKCEParams {
opts := GeneratePKCEOptions()
return &authhandler.PKCEParams{
Challenge: opts.Challenge,
ChallengeMethod: opts.ChallengeMethod,
Verifier: opts.Verifier,
}
}
63 changes: 63 additions & 0 deletions util/pkce_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package util

import (
"crypto/sha256"
"encoding/base64"
"testing"

"github.com/google/uuid"
)

func TestGeneratePKCEOptions(t *testing.T) {
opts1 := GeneratePKCEOptions()
if opts1 == nil {
t.Fatal("GeneratePKCEOptions() = nil, want non-nil")
}
if opts1.ChallengeMethod != "S256" {
t.Errorf("ChallengeMethod = %q, want %q", opts1.ChallengeMethod, "S256")
}
if _, err := uuid.Parse(opts1.Verifier); err != nil {
t.Errorf("Verifier %q is not a valid UUID: %v", opts1.Verifier, err)
}

sha := sha256.Sum256([]byte(opts1.Verifier))
wantChallenge := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(sha[:])
if opts1.Challenge != wantChallenge {
t.Errorf("Challenge = %q, want %q", opts1.Challenge, wantChallenge)
}

opts2 := GeneratePKCEOptions()
if opts1.Verifier == opts2.Verifier {
t.Errorf("consecutive GeneratePKCEOptions() calls returned identical Verifier %q", opts1.Verifier)
}
}

func TestGeneratePKCEParams(t *testing.T) {
params := GeneratePKCEParams()
if params == nil {
t.Fatal("GeneratePKCEParams() = nil, want non-nil")
}
if params.ChallengeMethod != "S256" {
t.Errorf("ChallengeMethod = %q, want %q", params.ChallengeMethod, "S256")
}
if params.Verifier == "" {
t.Error("Verifier is empty, want non-empty")
}
if params.Challenge == "" {
t.Error("Challenge is empty, want non-empty")
}
}
Loading
Loading