blob: 32f92653c471f1cde201726ba7ba9346bcf471db [file] [edit]
/*
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 omni
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"fmt"
"os"
"slices"
sync "sync"
"time"
"golang.org/x/oauth2"
"google.golang.org/api/option"
gtransport "google.golang.org/api/transport/grpc"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/proto"
)
// certPool creates a x509.CertPool from the given CA certificate file.
func certPool(caCertFile string) (*x509.CertPool, error) {
ca, err := os.ReadFile(caCertFile)
if err != nil {
return nil, fmt.Errorf("failed to read CA cert file: %w", err)
}
capool := x509.NewCertPool()
if !capool.AppendCertsFromPEM(ca) {
return nil, fmt.Errorf("failed to append the CA certificate to CA pool")
}
return capool, nil
}
// clientCertificate loads client certificate and private key for mTLS.
func clientCertificate(clientCertificatePath string, clientKeyPath string) ([]tls.Certificate, error) {
if clientCertificatePath == "" && clientKeyPath == "" {
return nil, nil
}
if clientCertificatePath == "" || clientKeyPath == "" {
return nil, fmt.Errorf("both client certificate and client key must be provided for mTLS")
}
cert, err := tls.LoadX509KeyPair(clientCertificatePath, clientKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to load client cert and key: %w", err)
}
return []tls.Certificate{cert}, nil
}
// ConnectionOptions generates standard ClientOption credentials configurations for Spanner Omni.
func ConnectionOptions(usePlainText bool, caCertFile, clientCertFile, clientKeyFile string) ([]option.ClientOption, error) {
if usePlainText {
if caCertFile != "" || clientCertFile != "" || clientKeyFile != "" {
return nil, fmt.Errorf("cannot use plain text and provide TLS certificates at the same time")
}
return []option.ClientOption{
option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
}, nil
}
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
}
if caCertFile != "" {
capool, err := certPool(caCertFile)
if err != nil {
return nil, fmt.Errorf("failed to load root CA: %w", err)
}
tlsConfig.RootCAs = capool
}
if clientCertFile != "" || clientKeyFile != "" {
clientCerts, err := clientCertificate(clientCertFile, clientKeyFile)
if err != nil {
return nil, err
}
tlsConfig.Certificates = clientCerts
}
creds := credentials.NewTLS(tlsConfig)
return []option.ClientOption{
option.WithGRPCDialOption(grpc.WithTransportCredentials(creds)),
}, nil
}
// gRPC client definitions
// LoginServiceClient is the client interface for the LoginService.
type LoginServiceClient interface {
Login(ctx context.Context, opts ...grpc.CallOption) (LoginServiceLoginClient, error)
}
type loginServiceClient struct {
cc grpc.ClientConnInterface
}
// NewLoginServiceClient creates a new LoginServiceClient.
func NewLoginServiceClient(cc grpc.ClientConnInterface) LoginServiceClient {
return &loginServiceClient{cc}
}
func (c *loginServiceClient) Login(ctx context.Context, opts ...grpc.CallOption) (LoginServiceLoginClient, error) {
stream, err := c.cc.NewStream(ctx, &LoginServiceServiceDesc.Streams[0], "/google.spanner.auth.v1.LoginService/Login", opts...)
if err != nil {
return nil, err
}
x := &loginServiceLoginClient{stream}
return x, nil
}
// LoginServiceLoginClient is the stream client for LoginService.
type LoginServiceLoginClient interface {
Send(*LoginRequest) error
Recv() (*LoginResponse, error)
grpc.ClientStream
}
type loginServiceLoginClient struct {
grpc.ClientStream
}
func (x *loginServiceLoginClient) Send(m *LoginRequest) error {
return x.ClientStream.SendMsg(m)
}
func (x *loginServiceLoginClient) Recv() (*LoginResponse, error) {
m := new(LoginResponse)
if err := x.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
// LoginServiceServiceDesc is the service description for LoginService.
var LoginServiceServiceDesc = grpc.ServiceDesc{
ServiceName: "google.spanner.auth.v1.LoginService",
HandlerType: (*interface{})(nil),
Methods: []grpc.MethodDesc{},
Streams: []grpc.StreamDesc{
{
StreamName: "Login",
Handler: nil,
ServerStreams: true,
ClientStreams: true,
},
},
Metadata: "omni.proto",
}
// omniTokenSource implements oauth2.TokenSource to provide bearer tokens for Spanner Omni.
//
// Note: The token issued by Spanner Omni is a custom OPAQUE access token (a serialized
// and base64-encoded AccessToken proto) rather than an OAuth 2.0 token issued by an
// OAuth authorization server. We implement oauth2.TokenSource so that gRPC transport
// can automatically inject the token as an "authorization: Bearer <token>" header on
// outgoing RPCs and manage token caching and expiration tracking via oauth2.Token.Valid().
type omniTokenSource struct {
ctx context.Context
mu sync.Mutex
username string
password []byte
opts []option.ClientOption
token *oauth2.Token
}
// NewTokenSource creates a new TokenSource for Omni authentication.
func NewTokenSource(ctx context.Context, username string, password []byte, opts []option.ClientOption) oauth2.TokenSource {
tsOpts := append([]option.ClientOption{option.WithoutAuthentication()}, opts...)
return &omniTokenSource{
ctx: ctx,
username: username,
password: slices.Clone(password),
opts: tsOpts,
}
}
func (ts *omniTokenSource) Token() (*oauth2.Token, error) {
ts.mu.Lock()
defer ts.mu.Unlock()
if ts.token != nil && ts.token.Valid() {
return ts.token, nil
}
parentCtx := ts.ctx
if parentCtx == nil {
parentCtx = context.Background()
}
ctx, cancel := context.WithTimeout(parentCtx, 60*time.Second)
defer cancel()
// Add x-goog-api-client header to satisfy headers_enforcer in tests
ctx = metadata.AppendToOutgoingContext(ctx, "x-goog-api-client", "gl-go/1.22 grpc/")
cc, err := gtransport.Dial(ctx, ts.opts...)
if err != nil {
return nil, fmt.Errorf("failed to dial spanner omni: %w", err)
}
defer cc.Close()
client := NewLoginServiceClient(cc)
stream, err := client.Login(ctx)
if err != nil {
return nil, fmt.Errorf("failed to start login stream: %w", err)
}
defer stream.CloseSend()
handshakeReq := &LoginRequest{
Username: ts.username,
Request: &LoginRequest_HandshakeRequest{
HandshakeRequest: &PasswordAuthenticationHandshakeRequest{},
},
}
if err := stream.Send(handshakeReq); err != nil {
return nil, fmt.Errorf("failed to send handshake request: %w", err)
}
handshakeResp, err := stream.Recv()
if err != nil {
return nil, fmt.Errorf("failed to receive handshake response: %w", err)
}
method := handshakeResp.GetHandshakeResponse().GetPasswordAuthenticationProtocol()
if method != PasswordAuthenticationProtocol_PASSWORD_AUTHENTICATION_PROTOCOL_OPAQUE {
return nil, fmt.Errorf("server does not support OPAQUE authentication")
}
hashParams := handshakeResp.GetHandshakeResponse().GetHashParameters()
auth, err := newAuthenticator(ts.username, ts.password, hashParams)
if err != nil {
return nil, err
}
initReq, err := auth.InitialRequest()
if err != nil {
return nil, err
}
if err := stream.Send(initReq); err != nil {
return nil, fmt.Errorf("failed to send initial request: %w", err)
}
initResp, err := stream.Recv()
if err != nil {
return nil, fmt.Errorf("failed to receive initial response: %w", err)
}
finalReq, err := auth.FinalRequest(initResp)
if err != nil {
return nil, err
}
if err := stream.Send(finalReq); err != nil {
return nil, fmt.Errorf("failed to send final request: %w", err)
}
finalResp, err := stream.Recv()
if err != nil {
return nil, fmt.Errorf("failed to receive final response: %w", err)
}
accessToken := finalResp.GetAccessToken()
if accessToken == nil {
return nil, fmt.Errorf("no access token in final response")
}
accessTokenBytes, err := proto.Marshal(accessToken)
if err != nil {
return nil, fmt.Errorf("failed to marshal access token: %w", err)
}
exp := time.Now().Add(1 * time.Hour)
if accessToken.ExpirationTime != nil {
if t := accessToken.ExpirationTime.AsTime(); !t.IsZero() {
exp = t
}
}
// Spanner Omni access tokens are custom serialized AccessToken protos rather than OAuth2 tokens.
// We wrap the base64-encoded proto in an oauth2.Token so gRPC transport automatically
// attaches the "authorization: Bearer <token>" header on outgoing RPCs.
ts.token = &oauth2.Token{
AccessToken: base64.StdEncoding.EncodeToString(accessTokenBytes),
TokenType: "Bearer",
Expiry: exp,
}
return ts.token, nil
}