Skip to content
Draft
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
2 changes: 2 additions & 0 deletions api/authmethods/oidc_auth_method_attributes.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 48 additions & 0 deletions api/authmethods/option.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ require (

require (
al.essio.dev/pkg/shellescape v1.5.1 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
dario.cat/mergo v1.0.1 // indirect
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c=
cloud.google.com/go/compute v1.38.0 h1:MilCLYQW2m7Dku8hRIIKo4r0oKastlD74sSu16riYKs=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
Expand Down
36 changes: 26 additions & 10 deletions internal/auth/oidc/auth_method.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,17 @@ func NewAuthMethod(ctx context.Context, scopeId string, clientId string, clientS

a := &AuthMethod{
AuthMethod: &store.AuthMethod{
ScopeId: scopeId,
Name: opts.withName,
Description: opts.withDescription,
OperationalState: string(opts.withOperationalState),
Issuer: u,
ClientId: clientId,
ClientSecret: string(clientSecret),
MaxAge: int32(opts.withMaxAge),
ClaimsScopes: opts.withClaimsScopes,
ScopeId: scopeId,
Name: opts.withName,
Description: opts.withDescription,
OperationalState: string(opts.withOperationalState),
Issuer: u,
ClientId: clientId,
ClientSecret: string(clientSecret),
MaxAge: int32(opts.withMaxAge),
ClaimsScopes: opts.withClaimsScopes,
GoogleWorkspaceServiceAccountJson: opts.withGoogleWorkspaceServiceAccountJson,
GoogleWorkspaceAdminEmail: opts.withGoogleWorkspaceAdminEmail,
},
}
if opts.withApiUrl != nil {
Expand Down Expand Up @@ -166,6 +168,11 @@ func (am *AuthMethod) validate(ctx context.Context, caller errors.Op) error {
if am.MaxAge < -1 {
return errors.New(ctx, errors.InvalidParameter, caller, "max age cannot be less than -1")
}
hasServiceAccountJson := am.GoogleWorkspaceServiceAccountJson != ""
hasAdminEmail := am.GoogleWorkspaceAdminEmail != ""
if hasServiceAccountJson != hasAdminEmail {
return errors.New(ctx, errors.InvalidParameter, caller, "google_workspace_service_account_json and google_workspace_admin_email must both be set or both be empty")
}
return nil
}

Expand All @@ -179,9 +186,18 @@ func AllocAuthMethod() AuthMethod {
// Clone an AuthMethod.
func (am *AuthMethod) Clone() *AuthMethod {
cp := proto.Clone(am.AuthMethod)
return &AuthMethod{
cloned := &AuthMethod{
AuthMethod: cp.(*store.AuthMethod),
}
// The Google Workspace fields use proto field numbers beyond the compiled
// binary descriptor, so proto.Clone does not copy them. Copy manually.
cloned.GoogleWorkspaceServiceAccountJson = am.GoogleWorkspaceServiceAccountJson
cloned.GoogleWorkspaceAdminEmail = am.GoogleWorkspaceAdminEmail
if len(am.CtGoogleWorkspaceServiceAccountJson) > 0 {
cloned.CtGoogleWorkspaceServiceAccountJson = make([]byte, len(am.CtGoogleWorkspaceServiceAccountJson))
copy(cloned.CtGoogleWorkspaceServiceAccountJson, am.CtGoogleWorkspaceServiceAccountJson)
}
return cloned
}

// TableName returns the table name.
Expand Down
142 changes: 142 additions & 0 deletions internal/auth/oidc/google_workspace.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright IBM Corp. 2020, 2026
// SPDX-License-Identifier: BUSL-1.1

package oidc

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"

"github.com/hashicorp/boundary/internal/errors"
"golang.org/x/oauth2/google"
)

const (
// googleDirectoryGroupsURL is the Admin SDK endpoint for listing a user's groups.
googleDirectoryGroupsURL = "https://admin.googleapis.com/admin/directory/v1/groups"

// googleDirectoryScope is the read-only OAuth2 scope for the Admin SDK
// Directory API groups resource.
googleDirectoryScope = "https://www.googleapis.com/auth/admin.directory.group.readonly"
)

// googleDirectoryGroupsResponse models the relevant fields from the Admin SDK
// Groups.list response.
type googleDirectoryGroupsResponse struct {
Groups []struct {
Email string `json:"email"`
} `json:"groups"`
NextPageToken string `json:"nextPageToken"`
}

// googleHTTPClientFunc creates an *http.Client authenticated to call the Google
// Admin SDK. It is a package-level variable so tests can substitute a mock
// without making real OAuth2 token requests.
var googleHTTPClientFunc = defaultGoogleHTTPClient

// defaultGoogleHTTPClient parses a service account JSON key and returns an
// HTTP client that impersonates adminEmail via domain-wide delegation.
func defaultGoogleHTTPClient(ctx context.Context, serviceAccountJSON, adminEmail string) (*http.Client, error) {
const op = "oidc.defaultGoogleHTTPClient"
conf, err := google.JWTConfigFromJSON([]byte(serviceAccountJSON), googleDirectoryScope)
if err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("failed to parse Google service account JSON"))
}
// Domain-wide delegation: impersonate the Workspace admin to call the
// Directory API on behalf of any user in the domain.
conf.Subject = adminEmail
return conf.Client(ctx), nil
}

// fetchGoogleWorkspaceGroups calls the Google Admin SDK Directory API to
// return the email addresses of all Google Workspace groups that userEmail
// belongs to.
//
// serviceAccountJSON is the plaintext content of a Google service account JSON
// key file whose associated service account has been granted domain-wide
// delegation with the scope https://www.googleapis.com/auth/admin.directory.group.readonly.
//
// adminEmail is a Google Workspace admin email address that Boundary
// impersonates when calling the API.
//
// On error the function returns a non-nil error; callers should log the error
// and continue authentication without group claims rather than blocking login.
func fetchGoogleWorkspaceGroups(ctx context.Context, serviceAccountJSON, adminEmail, userEmail string) ([]string, error) {
const op = "oidc.fetchGoogleWorkspaceGroups"
if serviceAccountJSON == "" {
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing service account JSON")
}
if adminEmail == "" {
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing admin email")
}
if userEmail == "" {
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing user email")
}

httpClient, err := googleHTTPClientFunc(ctx, serviceAccountJSON, adminEmail)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}

return listGoogleGroups(ctx, httpClient, userEmail)
}

// listGoogleGroups pages through the Admin SDK Groups.list endpoint and
// returns all group email addresses the user belongs to. It is separated from
// fetchGoogleWorkspaceGroups so tests can inject a pre-authenticated client
// directly without going through googleHTTPClientFunc.
func listGoogleGroups(ctx context.Context, httpClient *http.Client, userEmail string) ([]string, error) {
const op = "oidc.listGoogleGroups"
var allGroups []string
pageToken := ""

for {
reqURL := fmt.Sprintf("%s?userKey=%s&maxResults=200",
googleDirectoryGroupsURL, url.QueryEscape(userEmail))
if pageToken != "" {
reqURL += "&pageToken=" + url.QueryEscape(pageToken)
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("failed to build Directory API request"))
}

resp, err := httpClient.Do(req)
if err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("Directory API request failed"))
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, errors.Wrap(ctx, readErr, op, errors.WithMsg("failed to read Directory API response body"))
}

if resp.StatusCode != http.StatusOK {
return nil, errors.New(ctx, errors.Unknown, op,
fmt.Sprintf("Directory API returned HTTP %d: %s", resp.StatusCode, body))
}

var result googleDirectoryGroupsResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("failed to decode Directory API response"))
}

for _, g := range result.Groups {
if g.Email != "" {
allGroups = append(allGroups, g.Email)
}
}

if result.NextPageToken == "" {
break
}
pageToken = result.NextPageToken
}

return allGroups, nil
}
Loading