diff --git a/app/cli/cmd/auth_login.go b/app/cli/cmd/auth_login.go index 3912db0a7..698cc7cd9 100644 --- a/app/cli/cmd/auth_login.go +++ b/app/cli/cmd/auth_login.go @@ -33,10 +33,55 @@ import ( jwt "github.com/golang-jwt/jwt/v5" "github.com/spf13/cobra" "github.com/spf13/viper" + "golang.org/x/oauth2" ) type app struct { serverChan chan error + callback *loginCallback +} + +// loginCallback turns the callback of a browser login into the session token +type loginCallback struct { + callbackURL string + // codeVerifier is set when the login uses the code exchange + codeVerifier string + // whether the control plane still allows the token in the callback URL + legacyAllowed bool + exchange func(ctx context.Context, handoff, codeVerifier, callbackURL string) (string, error) + warn func(msg string) +} + +var errNoCallbackCredentials = errors.New("the login callback did not carry a login code") + +// token returns the session token for the callback request +func (c *loginCallback) token(r *http.Request) (string, error) { + q := r.URL.Query() + if handoff := q.Get(oauth.QueryParamHandoff); handoff != "" && c.codeVerifier != "" { + token, err := c.exchange(r.Context(), handoff, c.codeVerifier, c.callbackURL) + if err != nil { + return "", fmt.Errorf("exchanging the login code: %w", err) + } + return token, nil + } + + token := q.Get(oauth.QueryParamLegacyToken) + if token == "" { + return "", errNoCallbackCredentials + } + + // An older control plane, the token in the callback URL is the only method it has + if c.codeVerifier == "" { + return token, nil + } + + // The control plane supports the exchange, but the login page did not forward the code challenge + if !c.legacyAllowed { + return "", errors.New("the login returned the session token in the callback URL, which the control plane does not allow, refusing it") + } + + 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") + return token, nil } func newAuthLoginCmd() *cobra.Command { @@ -61,12 +106,12 @@ func interactiveAuth(forceHeadless bool) error { return err } // Get the auth login from the control plane directly - authURL, err := retrieveLoginURL() + info, err := retrieveLoginInfo() if err != nil { return err } - serverLoginURL, err := url.Parse(authURL) + serverLoginURL, err := url.Parse(info.GetLoginUrl()) if err != nil { return err } @@ -80,6 +125,18 @@ func interactiveAuth(forceHeadless bool) error { return headlessAuth(serverLoginURL) } + a.callback = &loginCallback{ + callbackURL: callbackURL.String(), + legacyAllowed: info.GetLoginLegacyCallbackAllowed(), + exchange: exchangeHandoff, + warn: func(msg string) { logger.Warn().Msg(msg) }, + } + + // Ask for a login code instead of the token when the control plane supports it + if info.GetLoginCodeExchangeSupported() { + a.callback.codeVerifier = addCodeChallenge(serverLoginURL) + } + err = openbrowser(serverLoginURL.String()) if err != nil { logger.Debug().Err(err).Msg("falling back to manual login") @@ -109,11 +166,44 @@ func interactiveAuth(forceHeadless bool) error { return nil } -func headlessAuth(loginURL *url.URL) error { - // Remove cli-callback query parameter to indicate the server to show it inline +// addCodeChallenge adds a S256 code challenge to the login URL and returns its verifier +func addCodeChallenge(loginURL *url.URL) string { + verifier := oauth2.GenerateVerifier() + q := loginURL.Query() - q.Set("callback", "") + q.Set(oauth.QueryParamCodeChallenge, oauth2.S256ChallengeFromVerifier(verifier)) + q.Set(oauth.QueryParamCodeChallengeMethod, oauth.CodeChallengeMethodS256) loginURL.RawQuery = q.Encode() + + return verifier +} + +// exchangeHandoff trades the login code for the session token +func exchangeHandoff(ctx context.Context, handoff, codeVerifier, callbackURL string) (string, error) { + resp, err := pb.NewAuthServiceClient(ActionOpts.CPConnection).ExchangeHandoff(ctx, &pb.AuthServiceExchangeHandoffRequest{ + Handoff: handoff, + CodeVerifier: codeVerifier, + Callback: callbackURL, + }) + if err != nil { + return "", err + } + + return resp.GetToken(), nil +} + +// toHeadlessLoginURL removes the callback, so the server shows the token inline. The code +// challenge goes too: the headless page has no code to exchange, and a challenge needs a callback +func toHeadlessLoginURL(loginURL *url.URL) { + q := loginURL.Query() + q.Set(oauth.QueryParamCallback, "") + q.Del(oauth.QueryParamCodeChallenge) + q.Del(oauth.QueryParamCodeChallengeMethod) + loginURL.RawQuery = q.Encode() +} + +func headlessAuth(loginURL *url.URL) error { + toHeadlessLoginURL(loginURL) fmt.Printf("To authenticate, click on the following link and paste the result back here\n\n %s\n\n", loginURL.String()) fmt.Print("Enter Token: ") @@ -145,18 +235,19 @@ func saveAuthToken(token string) error { } func (a *app) handleCallback(w http.ResponseWriter, r *http.Request) { - token := r.URL.Query().Get("t") - + var err error defer func() { - a.serverChan <- nil + a.serverChan <- err }() - if token == "" { + token, err := a.callback.token(r) + if err != nil { w.WriteHeader(http.StatusExpectationFailed) + fmt.Fprintln(w, "login failed, please check the terminal") return } - if err := saveAuthToken(token); err != nil { + if err = saveAuthToken(token); err != nil { w.WriteHeader(http.StatusInternalServerError) return } @@ -197,14 +288,10 @@ func isRemoteSession() bool { return false } -// Retrieve loginURL from the control plane -func retrieveLoginURL() (string, error) { +// Retrieve the login URL and the login capabilities from the control plane +func retrieveLoginInfo() (*pb.InfozResponse, error) { client := pb.NewStatusServiceClient(ActionOpts.CPConnection) - resp, err := client.Infoz(context.Background(), &pb.InfozRequest{}) - if err != nil { - return "", err - } - return resp.LoginUrl, nil + return client.Infoz(context.Background(), &pb.InfozRequest{}) } // Create a local HTTP listener with a random available port diff --git a/app/cli/cmd/auth_login_test.go b/app/cli/cmd/auth_login_test.go new file mode 100644 index 000000000..fe4265d9e --- /dev/null +++ b/app/cli/cmd/auth_login_test.go @@ -0,0 +1,162 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// 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 cmd + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/chainloop-dev/chainloop/internal/oauth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +func TestAddCodeChallenge(t *testing.T) { + loginURL, err := url.Parse("https://cp.example/auth/login?callback=http%3A%2F%2Flocalhost%3A1234%2Fauth%2Fcallback") + require.NoError(t, err) + + verifier := addCodeChallenge(loginURL) + + q := loginURL.Query() + assert.Equal(t, oauth2.S256ChallengeFromVerifier(verifier), q.Get(oauth.QueryParamCodeChallenge)) + assert.Equal(t, oauth.CodeChallengeMethodS256, q.Get(oauth.QueryParamCodeChallengeMethod)) + // Existing parameters are kept, and the verifier never goes in the URL + assert.Equal(t, "http://localhost:1234/auth/callback", q.Get(oauth.QueryParamCallback)) + assert.NotContains(t, loginURL.String(), verifier) +} + +// The browser fallback switches to headless after the challenge was added +func TestToHeadlessLoginURL(t *testing.T) { + loginURL, err := url.Parse("https://cp.example/auth/login?callback=http%3A%2F%2Flocalhost%3A1234%2Fauth%2Fcallback&long-lived=true") + require.NoError(t, err) + addCodeChallenge(loginURL) + + toHeadlessLoginURL(loginURL) + + q := loginURL.Query() + assert.True(t, q.Has(oauth.QueryParamCallback)) + assert.Empty(t, q.Get(oauth.QueryParamCallback)) + assert.False(t, q.Has(oauth.QueryParamCodeChallenge)) + assert.False(t, q.Has(oauth.QueryParamCodeChallengeMethod)) + assert.Equal(t, "true", q.Get(oauth.QueryParamLongLived)) +} + +func TestLoginCallbackToken(t *testing.T) { + const ( + callbackURL = "http://localhost:1234/auth/callback" + handoffQuery = "handoff=the-code" + legacyQuery = "t=legacy-token" + testVerifier = "verifier" + ) + + testCases := []struct { + name string + // query of the callback request + query string + codeVerifier string + legacyAllowed bool + exchangeErr error + wantToken string + wantErr bool + wantExchange bool + wantWarning bool + }{ + { + name: "code exchange", + query: handoffQuery, + codeVerifier: testVerifier, + wantToken: "exchanged-token", + wantExchange: true, + }, + { + name: "failed exchange", + query: handoffQuery, + codeVerifier: testVerifier, + exchangeErr: errors.New("invalid login code"), + wantExchange: true, + wantErr: true, + }, + { + name: "legacy callback while allowed", + query: legacyQuery, + codeVerifier: testVerifier, + legacyAllowed: true, + wantToken: "legacy-token", + wantWarning: true, + }, + { + name: "legacy callback while denied", + query: legacyQuery, + codeVerifier: testVerifier, + legacyAllowed: false, + wantErr: true, + }, + { + name: "older control plane", + query: legacyQuery, + wantToken: "legacy-token", + }, + { + name: "handoff from an older control plane is not expected", + query: handoffQuery, + wantErr: true, + }, + { + name: "no credentials", + query: "", + codeVerifier: testVerifier, + wantErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var exchanged, warned bool + c := &loginCallback{ + callbackURL: callbackURL, + codeVerifier: tc.codeVerifier, + legacyAllowed: tc.legacyAllowed, + exchange: func(_ context.Context, handoff, codeVerifier, cb string) (string, error) { + exchanged = true + assert.Equal(t, "the-code", handoff) + assert.Equal(t, tc.codeVerifier, codeVerifier) + assert.Equal(t, callbackURL, cb) + return "exchanged-token", tc.exchangeErr + }, + warn: func(string) { warned = true }, + } + + r := httptest.NewRequest(http.MethodGet, "/auth/callback?"+tc.query, nil) + token, err := c.token(r) + + assert.Equal(t, tc.wantExchange, exchanged) + assert.Equal(t, tc.wantWarning, warned) + if tc.wantErr { + assert.Error(t, err) + assert.Empty(t, token) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.wantToken, token) + }) + } +} diff --git a/app/controlplane/api/controlplane/v1/auth.pb.go b/app/controlplane/api/controlplane/v1/auth.pb.go index 4c2dba623..1c87e8610 100644 --- a/app/controlplane/api/controlplane/v1/auth.pb.go +++ b/app/controlplane/api/controlplane/v1/auth.pb.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -22,6 +22,7 @@ package v1 import ( + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" @@ -110,15 +111,133 @@ func (*AuthServiceDeleteAccountResponse) Descriptor() ([]byte, []int) { return file_controlplane_v1_auth_proto_rawDescGZIP(), []int{1} } +type AuthServiceExchangeHandoffRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The handoff code received in the callback URL + Handoff string `protobuf:"bytes,1,opt,name=handoff,proto3" json:"handoff,omitempty"` + // The PKCE verifier whose S256 challenge was sent when the login started + CodeVerifier string `protobuf:"bytes,2,opt,name=code_verifier,json=codeVerifier,proto3" json:"code_verifier,omitempty"` + // The callback URL the login was started with + Callback string `protobuf:"bytes,3,opt,name=callback,proto3" json:"callback,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AuthServiceExchangeHandoffRequest) Reset() { + *x = AuthServiceExchangeHandoffRequest{} + mi := &file_controlplane_v1_auth_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AuthServiceExchangeHandoffRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AuthServiceExchangeHandoffRequest) ProtoMessage() {} + +func (x *AuthServiceExchangeHandoffRequest) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_v1_auth_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AuthServiceExchangeHandoffRequest.ProtoReflect.Descriptor instead. +func (*AuthServiceExchangeHandoffRequest) Descriptor() ([]byte, []int) { + return file_controlplane_v1_auth_proto_rawDescGZIP(), []int{2} +} + +func (x *AuthServiceExchangeHandoffRequest) GetHandoff() string { + if x != nil { + return x.Handoff + } + return "" +} + +func (x *AuthServiceExchangeHandoffRequest) GetCodeVerifier() string { + if x != nil { + return x.CodeVerifier + } + return "" +} + +func (x *AuthServiceExchangeHandoffRequest) GetCallback() string { + if x != nil { + return x.Callback + } + return "" +} + +type AuthServiceExchangeHandoffResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // User session token + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AuthServiceExchangeHandoffResponse) Reset() { + *x = AuthServiceExchangeHandoffResponse{} + mi := &file_controlplane_v1_auth_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AuthServiceExchangeHandoffResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AuthServiceExchangeHandoffResponse) ProtoMessage() {} + +func (x *AuthServiceExchangeHandoffResponse) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_v1_auth_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AuthServiceExchangeHandoffResponse.ProtoReflect.Descriptor instead. +func (*AuthServiceExchangeHandoffResponse) Descriptor() ([]byte, []int) { + return file_controlplane_v1_auth_proto_rawDescGZIP(), []int{3} +} + +func (x *AuthServiceExchangeHandoffResponse) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + var File_controlplane_v1_auth_proto protoreflect.FileDescriptor const file_controlplane_v1_auth_proto_rawDesc = "" + "\n" + - "\x1acontrolplane/v1/auth.proto\x12\x0fcontrolplane.v1\"!\n" + + "\x1acontrolplane/v1/auth.proto\x12\x0fcontrolplane.v1\x1a\x1bbuf/validate/validate.proto\"!\n" + "\x1fAuthServiceDeleteAccountRequest\"\"\n" + - " AuthServiceDeleteAccountResponse2\x83\x01\n" + + " AuthServiceDeleteAccountResponse\"\xa2\x01\n" + + "!AuthServiceExchangeHandoffRequest\x12$\n" + + "\ahandoff\x18\x01 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\x80\x02R\ahandoff\x12/\n" + + "\rcode_verifier\x18\x02 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\x80\x02R\fcodeVerifier\x12&\n" + + "\bcallback\x18\x03 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\x80\x10R\bcallback\":\n" + + "\"AuthServiceExchangeHandoffResponse\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token2\xff\x01\n" + "\vAuthService\x12t\n" + - "\rDeleteAccount\x120.controlplane.v1.AuthServiceDeleteAccountRequest\x1a1.controlplane.v1.AuthServiceDeleteAccountResponseBLZJgithub.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1;v1b\x06proto3" + "\rDeleteAccount\x120.controlplane.v1.AuthServiceDeleteAccountRequest\x1a1.controlplane.v1.AuthServiceDeleteAccountResponse\x12z\n" + + "\x0fExchangeHandoff\x122.controlplane.v1.AuthServiceExchangeHandoffRequest\x1a3.controlplane.v1.AuthServiceExchangeHandoffResponseBLZJgithub.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1;v1b\x06proto3" var ( file_controlplane_v1_auth_proto_rawDescOnce sync.Once @@ -132,16 +251,20 @@ func file_controlplane_v1_auth_proto_rawDescGZIP() []byte { return file_controlplane_v1_auth_proto_rawDescData } -var file_controlplane_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_controlplane_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_controlplane_v1_auth_proto_goTypes = []any{ - (*AuthServiceDeleteAccountRequest)(nil), // 0: controlplane.v1.AuthServiceDeleteAccountRequest - (*AuthServiceDeleteAccountResponse)(nil), // 1: controlplane.v1.AuthServiceDeleteAccountResponse + (*AuthServiceDeleteAccountRequest)(nil), // 0: controlplane.v1.AuthServiceDeleteAccountRequest + (*AuthServiceDeleteAccountResponse)(nil), // 1: controlplane.v1.AuthServiceDeleteAccountResponse + (*AuthServiceExchangeHandoffRequest)(nil), // 2: controlplane.v1.AuthServiceExchangeHandoffRequest + (*AuthServiceExchangeHandoffResponse)(nil), // 3: controlplane.v1.AuthServiceExchangeHandoffResponse } var file_controlplane_v1_auth_proto_depIdxs = []int32{ 0, // 0: controlplane.v1.AuthService.DeleteAccount:input_type -> controlplane.v1.AuthServiceDeleteAccountRequest - 1, // 1: controlplane.v1.AuthService.DeleteAccount:output_type -> controlplane.v1.AuthServiceDeleteAccountResponse - 1, // [1:2] is the sub-list for method output_type - 0, // [0:1] is the sub-list for method input_type + 2, // 1: controlplane.v1.AuthService.ExchangeHandoff:input_type -> controlplane.v1.AuthServiceExchangeHandoffRequest + 1, // 2: controlplane.v1.AuthService.DeleteAccount:output_type -> controlplane.v1.AuthServiceDeleteAccountResponse + 3, // 3: controlplane.v1.AuthService.ExchangeHandoff:output_type -> controlplane.v1.AuthServiceExchangeHandoffResponse + 2, // [2:4] is the sub-list for method output_type + 0, // [0:2] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name @@ -158,7 +281,7 @@ func file_controlplane_v1_auth_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_controlplane_v1_auth_proto_rawDesc), len(file_controlplane_v1_auth_proto_rawDesc)), NumEnums: 0, - NumMessages: 2, + NumMessages: 4, NumExtensions: 0, NumServices: 1, }, diff --git a/app/controlplane/api/controlplane/v1/auth.proto b/app/controlplane/api/controlplane/v1/auth.proto index b5387778d..a26870101 100644 --- a/app/controlplane/api/controlplane/v1/auth.proto +++ b/app/controlplane/api/controlplane/v1/auth.proto @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -17,10 +17,15 @@ syntax = "proto3"; package controlplane.v1; +import "buf/validate/validate.proto"; + option go_package = "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1;v1"; service AuthService { rpc DeleteAccount(AuthServiceDeleteAccountRequest) returns (AuthServiceDeleteAccountResponse); + // ExchangeHandoff trades the single-use handoff code that a login delivered to the client + // callback for the user session token. It does not require an existing session. + rpc ExchangeHandoff(AuthServiceExchangeHandoffRequest) returns (AuthServiceExchangeHandoffResponse); } // AuthServiceDeleteAccountResponse is the response for the DeleteAccount method. @@ -28,3 +33,26 @@ message AuthServiceDeleteAccountRequest {} // AuthServiceDeleteAccountResponse is the response for the DeleteAccount method. message AuthServiceDeleteAccountResponse {} + +message AuthServiceExchangeHandoffRequest { + // The handoff code received in the callback URL + string handoff = 1 [(buf.validate.field).string = { + min_len: 1 + max_len: 256 + }]; + // The PKCE verifier whose S256 challenge was sent when the login started + string code_verifier = 2 [(buf.validate.field).string = { + min_len: 1 + max_len: 256 + }]; + // The callback URL the login was started with + string callback = 3 [(buf.validate.field).string = { + min_len: 1 + max_len: 2048 + }]; +} + +message AuthServiceExchangeHandoffResponse { + // User session token + string token = 1; +} diff --git a/app/controlplane/api/controlplane/v1/auth_grpc.pb.go b/app/controlplane/api/controlplane/v1/auth_grpc.pb.go index 6d0694fb0..9ac5ac5d2 100644 --- a/app/controlplane/api/controlplane/v1/auth_grpc.pb.go +++ b/app/controlplane/api/controlplane/v1/auth_grpc.pb.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -34,7 +34,8 @@ import ( const _ = grpc.SupportPackageIsVersion7 const ( - AuthService_DeleteAccount_FullMethodName = "/controlplane.v1.AuthService/DeleteAccount" + AuthService_DeleteAccount_FullMethodName = "/controlplane.v1.AuthService/DeleteAccount" + AuthService_ExchangeHandoff_FullMethodName = "/controlplane.v1.AuthService/ExchangeHandoff" ) // AuthServiceClient is the client API for AuthService service. @@ -42,6 +43,9 @@ const ( // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type AuthServiceClient interface { DeleteAccount(ctx context.Context, in *AuthServiceDeleteAccountRequest, opts ...grpc.CallOption) (*AuthServiceDeleteAccountResponse, error) + // ExchangeHandoff trades the single-use handoff code that a login delivered to the client + // callback for the user session token. It does not require an existing session. + ExchangeHandoff(ctx context.Context, in *AuthServiceExchangeHandoffRequest, opts ...grpc.CallOption) (*AuthServiceExchangeHandoffResponse, error) } type authServiceClient struct { @@ -61,11 +65,23 @@ func (c *authServiceClient) DeleteAccount(ctx context.Context, in *AuthServiceDe return out, nil } +func (c *authServiceClient) ExchangeHandoff(ctx context.Context, in *AuthServiceExchangeHandoffRequest, opts ...grpc.CallOption) (*AuthServiceExchangeHandoffResponse, error) { + out := new(AuthServiceExchangeHandoffResponse) + err := c.cc.Invoke(ctx, AuthService_ExchangeHandoff_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + // AuthServiceServer is the server API for AuthService service. // All implementations must embed UnimplementedAuthServiceServer // for forward compatibility type AuthServiceServer interface { DeleteAccount(context.Context, *AuthServiceDeleteAccountRequest) (*AuthServiceDeleteAccountResponse, error) + // ExchangeHandoff trades the single-use handoff code that a login delivered to the client + // callback for the user session token. It does not require an existing session. + ExchangeHandoff(context.Context, *AuthServiceExchangeHandoffRequest) (*AuthServiceExchangeHandoffResponse, error) mustEmbedUnimplementedAuthServiceServer() } @@ -76,6 +92,9 @@ type UnimplementedAuthServiceServer struct { func (UnimplementedAuthServiceServer) DeleteAccount(context.Context, *AuthServiceDeleteAccountRequest) (*AuthServiceDeleteAccountResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method DeleteAccount not implemented") } +func (UnimplementedAuthServiceServer) ExchangeHandoff(context.Context, *AuthServiceExchangeHandoffRequest) (*AuthServiceExchangeHandoffResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ExchangeHandoff not implemented") +} func (UnimplementedAuthServiceServer) mustEmbedUnimplementedAuthServiceServer() {} // UnsafeAuthServiceServer may be embedded to opt out of forward compatibility for this service. @@ -107,6 +126,24 @@ func _AuthService_DeleteAccount_Handler(srv interface{}, ctx context.Context, de return interceptor(ctx, in, info, handler) } +func _AuthService_ExchangeHandoff_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AuthServiceExchangeHandoffRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServiceServer).ExchangeHandoff(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthService_ExchangeHandoff_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServiceServer).ExchangeHandoff(ctx, req.(*AuthServiceExchangeHandoffRequest)) + } + return interceptor(ctx, in, info, handler) +} + // AuthService_ServiceDesc is the grpc.ServiceDesc for AuthService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -118,6 +155,10 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{ MethodName: "DeleteAccount", Handler: _AuthService_DeleteAccount_Handler, }, + { + MethodName: "ExchangeHandoff", + Handler: _AuthService_ExchangeHandoff_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "controlplane/v1/auth.proto", diff --git a/app/controlplane/api/controlplane/v1/status.pb.go b/app/controlplane/api/controlplane/v1/status.pb.go index f9d481789..a48106129 100644 --- a/app/controlplane/api/controlplane/v1/status.pb.go +++ b/app/controlplane/api/controlplane/v1/status.pb.go @@ -1,5 +1,5 @@ // -// Copyright 2023-2025 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -130,8 +130,12 @@ type InfozResponse struct { RestrictedOrgCreation bool `protobuf:"varint,4,opt,name=restricted_org_creation,json=restrictedOrgCreation,proto3" json:"restricted_org_creation,omitempty"` // Link to the platform UI, if available UiDashboardUrl string `protobuf:"bytes,5,opt,name=ui_dashboard_url,json=uiDashboardUrl,proto3" json:"ui_dashboard_url,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Whether the login can deliver a handoff code to be exchanged for the session token + LoginCodeExchangeSupported bool `protobuf:"varint,6,opt,name=login_code_exchange_supported,json=loginCodeExchangeSupported,proto3" json:"login_code_exchange_supported,omitempty"` + // Whether a login without a code challenge still receives the session token in the callback URL + LoginLegacyCallbackAllowed bool `protobuf:"varint,7,opt,name=login_legacy_callback_allowed,json=loginLegacyCallbackAllowed,proto3" json:"login_legacy_callback_allowed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *InfozResponse) Reset() { @@ -199,6 +203,20 @@ func (x *InfozResponse) GetUiDashboardUrl() string { return "" } +func (x *InfozResponse) GetLoginCodeExchangeSupported() bool { + if x != nil { + return x.LoginCodeExchangeSupported + } + return false +} + +func (x *InfozResponse) GetLoginLegacyCallbackAllowed() bool { + if x != nil { + return x.LoginLegacyCallbackAllowed + } + return false +} + type StatuszResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -242,13 +260,15 @@ const file_controlplane_v1_status_proto_rawDesc = "" + "\x1ccontrolplane/v1/status.proto\x12\x0fcontrolplane.v1\x1a\x1cgoogle/api/annotations.proto\"\x0e\n" + "\fInfozRequest\".\n" + "\x0eStatuszRequest\x12\x1c\n" + - "\treadiness\x18\x01 \x01(\bR\treadiness\"\xcd\x01\n" + + "\treadiness\x18\x01 \x01(\bR\treadiness\"\xd3\x02\n" + "\rInfozResponse\x12\x1b\n" + "\tlogin_url\x18\x01 \x01(\tR\bloginURL\x12\x18\n" + "\aversion\x18\x02 \x01(\tR\aversion\x12#\n" + "\rchart_version\x18\x03 \x01(\tR\fchartVersion\x126\n" + "\x17restricted_org_creation\x18\x04 \x01(\bR\x15restrictedOrgCreation\x12(\n" + - "\x10ui_dashboard_url\x18\x05 \x01(\tR\x0euiDashboardUrl\"\x11\n" + + "\x10ui_dashboard_url\x18\x05 \x01(\tR\x0euiDashboardUrl\x12A\n" + + "\x1dlogin_code_exchange_supported\x18\x06 \x01(\bR\x1aloginCodeExchangeSupported\x12A\n" + + "\x1dlogin_legacy_callback_allowed\x18\a \x01(\bR\x1aloginLegacyCallbackAllowed\"\x11\n" + "\x0fStatuszResponse2\xc7\x01\n" + "\rStatusService\x12V\n" + "\x05Infoz\x12\x1d.controlplane.v1.InfozRequest\x1a\x1e.controlplane.v1.InfozResponse\"\x0e\x82\xd3\xe4\x93\x02\b\x12\x06/infoz\x12^\n" + diff --git a/app/controlplane/api/controlplane/v1/status.proto b/app/controlplane/api/controlplane/v1/status.proto index 000c6ed71..1226cc774 100644 --- a/app/controlplane/api/controlplane/v1/status.proto +++ b/app/controlplane/api/controlplane/v1/status.proto @@ -1,5 +1,5 @@ // -// Copyright 2023-2025 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -48,6 +48,10 @@ message InfozResponse { bool restricted_org_creation = 4; // Link to the platform UI, if available string ui_dashboard_url = 5; + // Whether the login can deliver a handoff code to be exchanged for the session token + bool login_code_exchange_supported = 6; + // Whether a login without a code challenge still receives the session token in the callback URL + bool login_legacy_callback_allowed = 7; } message StatuszResponse {} diff --git a/app/controlplane/api/controlplane/v1/status_grpc.pb.go b/app/controlplane/api/controlplane/v1/status_grpc.pb.go index cfbcc4187..b246853ea 100644 --- a/app/controlplane/api/controlplane/v1/status_grpc.pb.go +++ b/app/controlplane/api/controlplane/v1/status_grpc.pb.go @@ -1,5 +1,5 @@ // -// Copyright 2023-2025 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/auth.ts b/app/controlplane/api/gen/frontend/controlplane/v1/auth.ts index 9367fa24f..f13bd6f52 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/auth.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/auth.ts @@ -13,6 +13,20 @@ export interface AuthServiceDeleteAccountRequest { export interface AuthServiceDeleteAccountResponse { } +export interface AuthServiceExchangeHandoffRequest { + /** The handoff code received in the callback URL */ + handoff: string; + /** The PKCE verifier whose S256 challenge was sent when the login started */ + codeVerifier: string; + /** The callback URL the login was started with */ + callback: string; +} + +export interface AuthServiceExchangeHandoffResponse { + /** User session token */ + token: string; +} + function createBaseAuthServiceDeleteAccountRequest(): AuthServiceDeleteAccountRequest { return {}; } @@ -105,11 +119,167 @@ export const AuthServiceDeleteAccountResponse = { }, }; +function createBaseAuthServiceExchangeHandoffRequest(): AuthServiceExchangeHandoffRequest { + return { handoff: "", codeVerifier: "", callback: "" }; +} + +export const AuthServiceExchangeHandoffRequest = { + encode(message: AuthServiceExchangeHandoffRequest, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.handoff !== "") { + writer.uint32(10).string(message.handoff); + } + if (message.codeVerifier !== "") { + writer.uint32(18).string(message.codeVerifier); + } + if (message.callback !== "") { + writer.uint32(26).string(message.callback); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): AuthServiceExchangeHandoffRequest { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseAuthServiceExchangeHandoffRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.handoff = reader.string(); + continue; + case 2: + if (tag !== 18) { + break; + } + + message.codeVerifier = reader.string(); + continue; + case 3: + if (tag !== 26) { + break; + } + + message.callback = reader.string(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): AuthServiceExchangeHandoffRequest { + return { + handoff: isSet(object.handoff) ? String(object.handoff) : "", + codeVerifier: isSet(object.codeVerifier) ? String(object.codeVerifier) : "", + callback: isSet(object.callback) ? String(object.callback) : "", + }; + }, + + toJSON(message: AuthServiceExchangeHandoffRequest): unknown { + const obj: any = {}; + message.handoff !== undefined && (obj.handoff = message.handoff); + message.codeVerifier !== undefined && (obj.codeVerifier = message.codeVerifier); + message.callback !== undefined && (obj.callback = message.callback); + return obj; + }, + + create, I>>( + base?: I, + ): AuthServiceExchangeHandoffRequest { + return AuthServiceExchangeHandoffRequest.fromPartial(base ?? {}); + }, + + fromPartial, I>>( + object: I, + ): AuthServiceExchangeHandoffRequest { + const message = createBaseAuthServiceExchangeHandoffRequest(); + message.handoff = object.handoff ?? ""; + message.codeVerifier = object.codeVerifier ?? ""; + message.callback = object.callback ?? ""; + return message; + }, +}; + +function createBaseAuthServiceExchangeHandoffResponse(): AuthServiceExchangeHandoffResponse { + return { token: "" }; +} + +export const AuthServiceExchangeHandoffResponse = { + encode(message: AuthServiceExchangeHandoffResponse, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.token !== "") { + writer.uint32(10).string(message.token); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): AuthServiceExchangeHandoffResponse { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseAuthServiceExchangeHandoffResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.token = reader.string(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): AuthServiceExchangeHandoffResponse { + return { token: isSet(object.token) ? String(object.token) : "" }; + }, + + toJSON(message: AuthServiceExchangeHandoffResponse): unknown { + const obj: any = {}; + message.token !== undefined && (obj.token = message.token); + return obj; + }, + + create, I>>( + base?: I, + ): AuthServiceExchangeHandoffResponse { + return AuthServiceExchangeHandoffResponse.fromPartial(base ?? {}); + }, + + fromPartial, I>>( + object: I, + ): AuthServiceExchangeHandoffResponse { + const message = createBaseAuthServiceExchangeHandoffResponse(); + message.token = object.token ?? ""; + return message; + }, +}; + export interface AuthService { DeleteAccount( request: DeepPartial, metadata?: grpc.Metadata, ): Promise; + /** + * ExchangeHandoff trades the single-use handoff code that a login delivered to the client + * callback for the user session token. It does not require an existing session. + */ + ExchangeHandoff( + request: DeepPartial, + metadata?: grpc.Metadata, + ): Promise; } export class AuthServiceClientImpl implements AuthService { @@ -118,6 +288,7 @@ export class AuthServiceClientImpl implements AuthService { constructor(rpc: Rpc) { this.rpc = rpc; this.DeleteAccount = this.DeleteAccount.bind(this); + this.ExchangeHandoff = this.ExchangeHandoff.bind(this); } DeleteAccount( @@ -126,6 +297,17 @@ export class AuthServiceClientImpl implements AuthService { ): Promise { return this.rpc.unary(AuthServiceDeleteAccountDesc, AuthServiceDeleteAccountRequest.fromPartial(request), metadata); } + + ExchangeHandoff( + request: DeepPartial, + metadata?: grpc.Metadata, + ): Promise { + return this.rpc.unary( + AuthServiceExchangeHandoffDesc, + AuthServiceExchangeHandoffRequest.fromPartial(request), + metadata, + ); + } } export const AuthServiceDesc = { serviceName: "controlplane.v1.AuthService" }; @@ -153,6 +335,29 @@ export const AuthServiceDeleteAccountDesc: UnaryMethodDefinitionish = { } as any, }; +export const AuthServiceExchangeHandoffDesc: UnaryMethodDefinitionish = { + methodName: "ExchangeHandoff", + service: AuthServiceDesc, + requestStream: false, + responseStream: false, + requestType: { + serializeBinary() { + return AuthServiceExchangeHandoffRequest.encode(this).finish(); + }, + } as any, + responseType: { + deserializeBinary(data: Uint8Array) { + const value = AuthServiceExchangeHandoffResponse.decode(data); + return { + ...value, + toObject() { + return value; + }, + }; + }, + } as any, +}; + interface UnaryMethodDefinitionishR extends grpc.UnaryMethodDefinition { requestStream: any; responseStream: any; @@ -251,6 +456,10 @@ type KeysOfUnion = T extends T ? keyof T : never; export type Exact = P extends Builtin ? P : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} + export class GrpcWebError extends tsProtoGlobalThis.Error { constructor(message: string, public code: grpc.Code, public metadata: grpc.Metadata) { super(message); diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/status.ts b/app/controlplane/api/gen/frontend/controlplane/v1/status.ts index 42472d414..5e08116c7 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/status.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/status.ts @@ -26,6 +26,10 @@ export interface InfozResponse { restrictedOrgCreation: boolean; /** Link to the platform UI, if available */ uiDashboardUrl: string; + /** Whether the login can deliver a handoff code to be exchanged for the session token */ + loginCodeExchangeSupported: boolean; + /** Whether a login without a code challenge still receives the session token in the callback URL */ + loginLegacyCallbackAllowed: boolean; } export interface StatuszResponse { @@ -132,7 +136,15 @@ export const StatuszRequest = { }; function createBaseInfozResponse(): InfozResponse { - return { loginUrl: "", version: "", chartVersion: "", restrictedOrgCreation: false, uiDashboardUrl: "" }; + return { + loginUrl: "", + version: "", + chartVersion: "", + restrictedOrgCreation: false, + uiDashboardUrl: "", + loginCodeExchangeSupported: false, + loginLegacyCallbackAllowed: false, + }; } export const InfozResponse = { @@ -152,6 +164,12 @@ export const InfozResponse = { if (message.uiDashboardUrl !== "") { writer.uint32(42).string(message.uiDashboardUrl); } + if (message.loginCodeExchangeSupported === true) { + writer.uint32(48).bool(message.loginCodeExchangeSupported); + } + if (message.loginLegacyCallbackAllowed === true) { + writer.uint32(56).bool(message.loginLegacyCallbackAllowed); + } return writer; }, @@ -197,6 +215,20 @@ export const InfozResponse = { message.uiDashboardUrl = reader.string(); continue; + case 6: + if (tag !== 48) { + break; + } + + message.loginCodeExchangeSupported = reader.bool(); + continue; + case 7: + if (tag !== 56) { + break; + } + + message.loginLegacyCallbackAllowed = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -213,6 +245,12 @@ export const InfozResponse = { chartVersion: isSet(object.chartVersion) ? String(object.chartVersion) : "", restrictedOrgCreation: isSet(object.restrictedOrgCreation) ? Boolean(object.restrictedOrgCreation) : false, uiDashboardUrl: isSet(object.uiDashboardUrl) ? String(object.uiDashboardUrl) : "", + loginCodeExchangeSupported: isSet(object.loginCodeExchangeSupported) + ? Boolean(object.loginCodeExchangeSupported) + : false, + loginLegacyCallbackAllowed: isSet(object.loginLegacyCallbackAllowed) + ? Boolean(object.loginLegacyCallbackAllowed) + : false, }; }, @@ -223,6 +261,10 @@ export const InfozResponse = { message.chartVersion !== undefined && (obj.chartVersion = message.chartVersion); message.restrictedOrgCreation !== undefined && (obj.restrictedOrgCreation = message.restrictedOrgCreation); message.uiDashboardUrl !== undefined && (obj.uiDashboardUrl = message.uiDashboardUrl); + message.loginCodeExchangeSupported !== undefined && + (obj.loginCodeExchangeSupported = message.loginCodeExchangeSupported); + message.loginLegacyCallbackAllowed !== undefined && + (obj.loginLegacyCallbackAllowed = message.loginLegacyCallbackAllowed); return obj; }, @@ -237,6 +279,8 @@ export const InfozResponse = { message.chartVersion = object.chartVersion ?? ""; message.restrictedOrgCreation = object.restrictedOrgCreation ?? false; message.uiDashboardUrl = object.uiDashboardUrl ?? ""; + message.loginCodeExchangeSupported = object.loginCodeExchangeSupported ?? false; + message.loginLegacyCallbackAllowed = object.loginLegacyCallbackAllowed ?? false; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.AuthServiceExchangeHandoffRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.AuthServiceExchangeHandoffRequest.jsonschema.json new file mode 100644 index 000000000..7c09d0b83 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.AuthServiceExchangeHandoffRequest.jsonschema.json @@ -0,0 +1,35 @@ +{ + "$id": "controlplane.v1.AuthServiceExchangeHandoffRequest.jsonschema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "patternProperties": { + "^(code_verifier)$": { + "description": "The PKCE verifier whose S256 challenge was sent when the login started", + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "properties": { + "callback": { + "description": "The callback URL the login was started with", + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "codeVerifier": { + "description": "The PKCE verifier whose S256 challenge was sent when the login started", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "handoff": { + "description": "The handoff code received in the callback URL", + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "title": "Auth Service Exchange Handoff Request", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.AuthServiceExchangeHandoffRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.AuthServiceExchangeHandoffRequest.schema.json new file mode 100644 index 000000000..4c3e16e17 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.AuthServiceExchangeHandoffRequest.schema.json @@ -0,0 +1,35 @@ +{ + "$id": "controlplane.v1.AuthServiceExchangeHandoffRequest.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "patternProperties": { + "^(codeVerifier)$": { + "description": "The PKCE verifier whose S256 challenge was sent when the login started", + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "properties": { + "callback": { + "description": "The callback URL the login was started with", + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "code_verifier": { + "description": "The PKCE verifier whose S256 challenge was sent when the login started", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "handoff": { + "description": "The handoff code received in the callback URL", + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "title": "Auth Service Exchange Handoff Request", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.AuthServiceExchangeHandoffResponse.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.AuthServiceExchangeHandoffResponse.jsonschema.json new file mode 100644 index 000000000..2b54c2e7a --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.AuthServiceExchangeHandoffResponse.jsonschema.json @@ -0,0 +1,13 @@ +{ + "$id": "controlplane.v1.AuthServiceExchangeHandoffResponse.jsonschema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "token": { + "description": "User session token", + "type": "string" + } + }, + "title": "Auth Service Exchange Handoff Response", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.AuthServiceExchangeHandoffResponse.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.AuthServiceExchangeHandoffResponse.schema.json new file mode 100644 index 000000000..238198fd0 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.AuthServiceExchangeHandoffResponse.schema.json @@ -0,0 +1,13 @@ +{ + "$id": "controlplane.v1.AuthServiceExchangeHandoffResponse.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "token": { + "description": "User session token", + "type": "string" + } + }, + "title": "Auth Service Exchange Handoff Response", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.InfozResponse.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.InfozResponse.jsonschema.json index bf18723c7..c1aa04af5 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.InfozResponse.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.InfozResponse.jsonschema.json @@ -7,6 +7,14 @@ "description": "Version of the helm chart used during deployment", "type": "string" }, + "^(login_code_exchange_supported)$": { + "description": "Whether the login can deliver a handoff code to be exchanged for the session token", + "type": "boolean" + }, + "^(login_legacy_callback_allowed)$": { + "description": "Whether a login without a code challenge still receives the session token in the callback URL", + "type": "boolean" + }, "^(login_url)$": { "type": "string" }, @@ -24,6 +32,14 @@ "description": "Version of the helm chart used during deployment", "type": "string" }, + "loginCodeExchangeSupported": { + "description": "Whether the login can deliver a handoff code to be exchanged for the session token", + "type": "boolean" + }, + "loginLegacyCallbackAllowed": { + "description": "Whether a login without a code challenge still receives the session token in the callback URL", + "type": "boolean" + }, "loginURL": { "type": "string" }, diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.InfozResponse.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.InfozResponse.schema.json index 0dad3dbac..93c5c9568 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.InfozResponse.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.InfozResponse.schema.json @@ -7,6 +7,14 @@ "description": "Version of the helm chart used during deployment", "type": "string" }, + "^(loginCodeExchangeSupported)$": { + "description": "Whether the login can deliver a handoff code to be exchanged for the session token", + "type": "boolean" + }, + "^(loginLegacyCallbackAllowed)$": { + "description": "Whether a login without a code challenge still receives the session token in the callback URL", + "type": "boolean" + }, "^(loginURL)$": { "type": "string" }, @@ -24,6 +32,14 @@ "description": "Version of the helm chart used during deployment", "type": "string" }, + "login_code_exchange_supported": { + "description": "Whether the login can deliver a handoff code to be exchanged for the session token", + "type": "boolean" + }, + "login_legacy_callback_allowed": { + "description": "Whether a login without a code challenge still receives the session token in the callback URL", + "type": "boolean" + }, "login_url": { "type": "string" }, diff --git a/app/controlplane/cmd/main.go b/app/controlplane/cmd/main.go index 642beb1fe..bb83fa78f 100644 --- a/app/controlplane/cmd/main.go +++ b/app/controlplane/cmd/main.go @@ -64,7 +64,7 @@ func init() { func newApp(logger log.Logger, gs *grpc.Server, hs *http.Server, ms *server.HTTPMetricsServer, profilerSvc *server.HTTPProfilerServer, expirer *biz.WorkflowRunExpirerUseCase, plugins sdk.AvailablePlugins, userAccessSyncer *biz.UserAccessSyncerUseCase, casBackendChecker *biz.CASBackendChecker, - apiTokenStaleRevoker *biz.APITokenStaleRevoker, cfg *conf.Bootstrap) *app { + apiTokenStaleRevoker *biz.APITokenStaleRevoker, loginHandoffs *biz.LoginHandoffUseCase, cfg *conf.Bootstrap) *app { servers := []transport.Server{gs, hs, ms} if cfg.EnableProfiler { servers = append(servers, profilerSvc) @@ -78,7 +78,7 @@ func newApp(logger log.Logger, gs *grpc.Server, hs *http.Server, ms *server.HTTP kratos.Metadata(map[string]string{}), kratos.Logger(logger), kratos.Server(servers...), - ), expirer, plugins, userAccessSyncer, casBackendChecker, apiTokenStaleRevoker} + ), expirer, plugins, userAccessSyncer, casBackendChecker, apiTokenStaleRevoker, loginHandoffs} } func main() { @@ -180,6 +180,9 @@ func main() { InitialDelay: initialDelay, }) + // Start the background sweeper of expired login handoff codes + go app.loginHandoffs.StartSweeper(ctx) + // Start the background CAS Backend checker for DEFAULT backends (every 30 minutes) if app.casBackendChecker != nil { go app.casBackendChecker.Start(ctx, &biz.CASBackendCheckerOpts{ @@ -229,6 +232,8 @@ type app struct { casBackendChecker *biz.CASBackendChecker // Background sweeper that auto-revokes stale API tokens apiTokenStaleRevoker *biz.APITokenStaleRevoker + // Background sweeper that deletes expired login handoff codes + loginHandoffs *biz.LoginHandoffUseCase } // newNatsConfig converts the proto config to a plain natsconn.Config. @@ -270,6 +275,8 @@ func filterSensitiveArgs(_ log.Level, keyvals ...interface{}) bool { maskArgs(keyvals) case "/controlplane.v1.AttestationStateService/Save": maskArgs(keyvals) + case "/controlplane.v1.AuthService/ExchangeHandoff": + maskArgs(keyvals) } } } diff --git a/app/controlplane/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go index c91f4a107..9da2a1733 100644 --- a/app/controlplane/cmd/wire_gen.go +++ b/app/controlplane/cmd/wire_gen.go @@ -179,7 +179,9 @@ func wireApp(contextContext context.Context, bootstrap *conf.Bootstrap, readerWr projectUseCase := biz.NewProjectsUseCase(logger, projectsRepo, membershipRepo, auditorUseCase, groupUseCase, membershipUseCase, orgInvitationUseCase, orgInvitationRepo, authzUseCase) v5 := serviceOpts(logger, authzUseCase, projectUseCase, groupUseCase) workflowService := service.NewWorkflowService(workflowUseCase, workflowContractUseCase, projectUseCase, organizationUseCase, userUseCase, v5...) - authService, err := service.NewAuthService(userUseCase, organizationUseCase, membershipUseCase, orgInvitationUseCase, auth, bootstrap, auditorUseCase, v5...) + loginHandoffRepo := data.NewLoginHandoffRepo(dataData, logger) + loginHandoffUseCase := biz.NewLoginHandoffUseCase(loginHandoffRepo, logger) + authService, err := service.NewAuthService(userUseCase, organizationUseCase, membershipUseCase, orgInvitationUseCase, loginHandoffUseCase, auth, bootstrap, auditorUseCase, v5...) if err != nil { cleanup3() cleanup2() @@ -403,7 +405,7 @@ func wireApp(contextContext context.Context, bootstrap *conf.Bootstrap, readerWr distributedLock := data.NewPostgresLock(dataData, logger) casBackendChecker := biz.NewCASBackendChecker(logger, casBackendRepo, casBackendUseCase, distributedLock) apiTokenStaleRevoker := biz.NewAPITokenStaleRevoker(organizationRepo, apiTokenRepo, apiTokenUseCase, logger) - mainApp := newApp(logger, grpcServer, httpServer, httpMetricsServer, httpProfilerServer, workflowRunExpirerUseCase, availablePlugins, userAccessSyncerUseCase, casBackendChecker, apiTokenStaleRevoker, bootstrap) + mainApp := newApp(logger, grpcServer, httpServer, httpMetricsServer, httpProfilerServer, workflowRunExpirerUseCase, availablePlugins, userAccessSyncerUseCase, casBackendChecker, apiTokenStaleRevoker, loginHandoffUseCase, bootstrap) return mainApp, func() { cleanup3() cleanup2() diff --git a/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go b/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go index 09eefa43b..16c083973 100644 --- a/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go +++ b/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go @@ -735,9 +735,13 @@ type Auth struct { CasRobotAccountPrivateKeyPath string `protobuf:"bytes,4,opt,name=cas_robot_account_private_key_path,json=casRobotAccountPrivateKeyPath,proto3" json:"cas_robot_account_private_key_path,omitempty"` Oidc *Auth_OIDC `protobuf:"bytes,6,opt,name=oidc,proto3" json:"oidc,omitempty"` // Generates an initial user. Use only for development purposes - DevUser string `protobuf:"bytes,7,opt,name=dev_user,json=devUser,proto3" json:"dev_user,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + DevUser string `protobuf:"bytes,7,opt,name=dev_user,json=devUser,proto3" json:"dev_user,omitempty"` + // Whether a login that sends no code challenge still receives the session token in the + // callback URL (legacy ?t= callback). Allowed when not set. Denying it requires clients + // that use the login code exchange. + AllowLegacyLoginCallback *bool `protobuf:"varint,8,opt,name=allow_legacy_login_callback,json=allowLegacyLoginCallback,proto3,oneof" json:"allow_legacy_login_callback,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Auth) Reset() { @@ -805,6 +809,13 @@ func (x *Auth) GetDevUser() string { return "" } +func (x *Auth) GetAllowLegacyLoginCallback() bool { + if x != nil && x.AllowLegacyLoginCallback != nil { + return *x.AllowLegacyLoginCallback + } + return false +} + type TSA struct { state protoimpl.MessageState `protogen:"open.v1"` // TSA service URL for generating timestamps @@ -1958,19 +1969,21 @@ const file_controlplane_config_v1_conf_proto_rawDesc = "" + "\x06source\x18\x02 \x01(\tR\x06source\x12$\n" + "\x0emin_open_conns\x18\x03 \x01(\x05R\fminOpenConns\x12$\n" + "\x0emax_open_conns\x18\x04 \x01(\x05R\fmaxOpenConns\x12F\n" + - "\x12max_conn_idle_time\x18\x05 \x01(\v2\x19.google.protobuf.DurationR\x0fmaxConnIdleTime\"\xb1\x03\n" + + "\x12max_conn_idle_time\x18\x05 \x01(\v2\x19.google.protobuf.DurationR\x0fmaxConnIdleTime\"\x95\x04\n" + "\x04Auth\x129\n" + "\x19generated_jws_hmac_secret\x18\x02 \x01(\tR\x16generatedJwsHmacSecret\x12@\n" + "\n" + "allow_list\x18\x03 \x01(\v2!.controlplane.config.v1.AllowListR\tallowList\x12I\n" + "\"cas_robot_account_private_key_path\x18\x04 \x01(\tR\x1dcasRobotAccountPrivateKeyPath\x125\n" + "\x04oidc\x18\x06 \x01(\v2!.controlplane.config.v1.Auth.OIDCR\x04oidc\x12\x19\n" + - "\bdev_user\x18\a \x01(\tR\adevUser\x1a\x8e\x01\n" + + "\bdev_user\x18\a \x01(\tR\adevUser\x12B\n" + + "\x1ballow_legacy_login_callback\x18\b \x01(\bH\x00R\x18allowLegacyLoginCallback\x88\x01\x01\x1a\x8e\x01\n" + "\x04OIDC\x12\x16\n" + "\x06domain\x18\x01 \x01(\tR\x06domain\x12\x1b\n" + "\tclient_id\x18\x02 \x01(\tR\bclientId\x12#\n" + "\rclient_secret\x18\x03 \x01(\tR\fclientSecret\x12,\n" + - "\x12login_url_override\x18\x05 \x01(\tR\x10loginUrlOverride\"W\n" + + "\x12login_url_override\x18\x05 \x01(\tR\x10loginUrlOverrideB\x1e\n" + + "\x1c_allow_legacy_login_callback\"W\n" + "\x03TSA\x12\x10\n" + "\x03url\x18\x01 \x01(\tR\x03url\x12&\n" + "\x0fcert_chain_path\x18\x02 \x01(\tR\rcertChainPath\x12\x16\n" + @@ -2088,6 +2101,7 @@ func file_controlplane_config_v1_conf_proto_init() { return } file_controlplane_config_v1_conf_proto_msgTypes[2].OneofWrappers = []any{} + file_controlplane_config_v1_conf_proto_msgTypes[8].OneofWrappers = []any{} file_controlplane_config_v1_conf_proto_msgTypes[10].OneofWrappers = []any{ (*CA_FileCa)(nil), (*CA_EjbcaCa)(nil), diff --git a/app/controlplane/internal/conf/controlplane/config/v1/conf.proto b/app/controlplane/internal/conf/controlplane/config/v1/conf.proto index f53e81de3..36140d8a3 100644 --- a/app/controlplane/internal/conf/controlplane/config/v1/conf.proto +++ b/app/controlplane/internal/conf/controlplane/config/v1/conf.proto @@ -273,6 +273,11 @@ message Auth { // Generates an initial user. Use only for development purposes string dev_user = 7; + // Whether a login that sends no code challenge still receives the session token in the + // callback URL (legacy ?t= callback). Allowed when not set. Denying it requires clients + // that use the login code exchange. + optional bool allow_legacy_login_callback = 8; + message OIDC { string domain = 1; string client_id = 2; diff --git a/app/controlplane/internal/server/grpc.go b/app/controlplane/internal/server/grpc.go index faca055b0..f781d1481 100644 --- a/app/controlplane/internal/server/grpc.go +++ b/app/controlplane/internal/server/grpc.go @@ -101,7 +101,7 @@ type Opts struct { } var ( - currentUserSkipRegexp = regexp.MustCompile("(controlplane.v1.AttestationService/.*|controlplane.v1.StatusService/.*|controlplane.v1.AttestationStateService|controlplane.v1.SigningService)") + currentUserSkipRegexp = regexp.MustCompile("(controlplane.v1.AttestationService/.*|controlplane.v1.StatusService/.*|/controlplane.v1.AuthService/ExchangeHandoff$|controlplane.v1.AttestationStateService|controlplane.v1.SigningService)") fullyConfiguredOrgSkipRegexp = regexp.MustCompile("controlplane.v1.OCIRepositoryService/.*|controlplane.v1.ContextService/Current|/controlplane.v1.OrganizationService/.*|/controlplane.v1.AuthService/DeleteAccount|controlplane.v1.CASBackendService/.*|/controlplane.v1.UserService/.*|controlplane.v1.SigningService/.*") fullyConfiguredCASBackendRequireRegexp = regexp.MustCompile("/controlplane.v1.AttestationService.GetUploadCreds|/controlplane.v1.AttestationService.Init|/controlplane.v1.AttestationService.Store|/controlplane.v1.CASCredentialsService.Get") allowListSkipRegexp = regexp.MustCompile("controlplane.v1.ContextService/Current|/controlplane.v1.AuthService/DeleteAccount") diff --git a/app/controlplane/internal/server/grpc_test.go b/app/controlplane/internal/server/grpc_test.go index 9a35c96b8..9c25ffcac 100644 --- a/app/controlplane/internal/server/grpc_test.go +++ b/app/controlplane/internal/server/grpc_test.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -33,6 +33,9 @@ func TestRequireCurrentUserMatcher(t *testing.T) { {"/controlplane.v1.StatusService/Statusz", false}, {"/controlplane.v1.AttestationService/Init", false}, {"/controlplane.v1.AttestationService/Store", false}, + {"/controlplane.v1.AuthService/ExchangeHandoff", false}, + {"/controlplane.v1.AuthService/ExchangeHandoffX", true}, + {"/controlplane.v1.AuthService/DeleteAccount", true}, } matchFunc := requireCurrentUserMatcher() diff --git a/app/controlplane/internal/service/auth.go b/app/controlplane/internal/service/auth.go index df459f1ea..3217e7c3c 100644 --- a/app/controlplane/internal/service/auth.go +++ b/app/controlplane/internal/service/auth.go @@ -28,6 +28,7 @@ import ( "slices" "strings" "time" + "unicode" pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1" @@ -37,7 +38,10 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/jwt/user" "github.com/chainloop-dev/chainloop/internal/oauth" sl "github.com/chainloop-dev/chainloop/pkg/servicelogger" + kerrors "github.com/go-kratos/kratos/v2/errors" "github.com/go-kratos/kratos/v2/log" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" "golang.org/x/oauth2" ) @@ -49,6 +53,7 @@ const ( cookieOauthStateName = "oauthState" cookieCallback = "oauthCallback" cookieLongLived = "longLived" + cookieCodeChallenge = "oauthCodeChallenge" // Auth paths AuthLoginPath = "/auth/login" @@ -64,6 +69,24 @@ const ( devUserDuration = 30 * 24 * time.Hour ) +// legacyCallbackDeliveries counts the logins that sent the session token in the callback URL +var legacyCallbackDeliveries = promauto.NewCounter(prometheus.CounterOpts{ + Name: "chainloop_controlplane_auth_legacy_callback_deliveries_total", + Help: "Logins that delivered the session token in the callback URL instead of a handoff code.", +}) + +// errLegacyCallbackDenied is shown to a user whose client does not send a code challenge +var errLegacyCallbackDenied = errors.New("this client is not supported by the server anymore, please upgrade it and log in again") + +// errLoginHandoffExchange is the only error a client gets from a failed exchange, the reason goes to the server log +var errLoginHandoffExchange = kerrors.Unauthorized("LOGIN_HANDOFF_INVALID", "invalid login code, please log in again") + +// loginHandoffs issues and exchanges the single-use codes a login delivers to the client callback +type loginHandoffs interface { + Issue(ctx context.Context, userID string, tokenTTL time.Duration, codeChallenge, callback string) (string, error) + Exchange(ctx context.Context, code, codeVerifier, callback string) (*biz.LoginHandoff, error) +} + type oauthResp struct { code int err error @@ -100,6 +123,8 @@ func (e *oauthResp) ErrorMessage(ctx context.Context, l *log.Helper) string { return "" } +// oauthHandler writes the error page for the response a handler returns. A handler that already +// answered the request, with a redirect or a page, returns nil type oauthHandler struct { H func(*AuthService, http.ResponseWriter, *http.Request) *oauthResp svc *AuthService @@ -117,12 +142,13 @@ type AuthService struct { orgInvitesUseCase *biz.OrgInvitationUseCase AuthURLs *AuthURLs auditorUseCase *biz.AuditorUseCase + loginHandoffs loginHandoffs devMode bool // scheme://host destinations the post-login redirect may target allowedCallbackOrigins []string } -func NewAuthService(userUC *biz.UserUseCase, orgUC *biz.OrganizationUseCase, mUC *biz.MembershipUseCase, inviteUC *biz.OrgInvitationUseCase, authConfig *conf.Auth, bootstrapConfig *conf.Bootstrap, auc *biz.AuditorUseCase, opts ...NewOpt) (*AuthService, error) { +func NewAuthService(userUC *biz.UserUseCase, orgUC *biz.OrganizationUseCase, mUC *biz.MembershipUseCase, inviteUC *biz.OrgInvitationUseCase, handoffUC *biz.LoginHandoffUseCase, authConfig *conf.Auth, bootstrapConfig *conf.Bootstrap, auc *biz.AuditorUseCase, opts ...NewOpt) (*AuthService, error) { oidcConfig := authConfig.GetOidc() if oidcConfig == nil { return nil, errors.New("oauth configuration missing") @@ -162,6 +188,7 @@ func NewAuthService(userUC *biz.UserUseCase, orgUC *biz.OrganizationUseCase, mUC membershipUseCase: mUC, orgInvitesUseCase: inviteUC, auditorUseCase: auc, + loginHandoffs: handoffUC, allowedCallbackOrigins: append(originsOf(authURLs.Login, bootstrapConfig.GetServer().GetHttp().GetExternalUrl(), bootstrapConfig.GetUiDashboardUrl()), extraOrigins...), }, nil @@ -236,8 +263,12 @@ func callbackAllowed(callback string, allowedOrigins []string) error { return errInvalidCallback } - // Relative path. Both parts must be empty, otherwise "//evil.example" would pass as a path + // Relative reference. It must be a single-slash path: "////evil.example" parses with no host, + // but browsers resolve it off-origin if u.Scheme == "" && u.Host == "" { + if !isRelativePath(callback) { + return errInvalidCallback + } return nil } @@ -256,6 +287,94 @@ func callbackAllowed(callback string, allowedOrigins []string) error { return fmt.Errorf("callback URL not allowed: %s", originOf(u)) } +var errInvalidReturnTo = errors.New("invalid post-login redirect, it must be a relative path") + +// returnToAllowed rejects a callback whose post-login redirect is not a relative path, so the +// destination stays on the origin of the callback +func returnToAllowed(callback string) error { + if callback == "" { + return nil + } + + u, err := url.Parse(callback) + if err != nil { + return errInvalidCallback + } + + for _, v := range u.Query()[oauth.QueryParamReturnTo] { + if v != "" && !isRelativePath(v) { + return errInvalidReturnTo + } + } + + return nil +} + +// isRelativePath accepts only a path on the current origin, one that starts with a single "/" +func isRelativePath(p string) bool { + // "//host" (or any number of slashes) is scheme-relative + if !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") { + return false + } + + // Browsers fold "\" into "/", so "/\evil.example" would escape a seemingly relative path. They also + // strip tabs and newlines, so "//host" would become "//host" + if strings.Contains(p, `\`) || strings.ContainsFunc(p, unicode.IsControl) { + return false + } + + u, err := url.Parse(p) + return err == nil && u.Scheme == "" && u.Host == "" +} + +// LegacyCallbackAllowed tells if a login with no code challenge still gets the token in the callback URL +func LegacyCallbackAllowed(authConfig *conf.Auth) bool { + // Phase 1 of the deprecation: allowed unless the operator denies it + return authConfig == nil || authConfig.AllowLegacyLoginCallback == nil || authConfig.GetAllowLegacyLoginCallback() +} + +// isLegacyDelivery tells if a login would put the token in an external callback URL. A relative +// callback is the same-origin download flow, which is not part of the deprecation +func isLegacyDelivery(callback, codeChallenge string) bool { + return callback != "" && codeChallenge == "" && !isRelativePath(callback) +} + +// checkLegacyCallback rejects a legacy delivery when the operator denied it +func (svc *AuthService) checkLegacyCallback(callback, codeChallenge string) error { + if isLegacyDelivery(callback, codeChallenge) && !LegacyCallbackAllowed(svc.authConfig) { + return errLegacyCallbackDenied + } + + return nil +} + +// userToken creates the session token of a login, the same for every delivery method +func (svc *AuthService) userToken(userID string, ttl time.Duration) (string, error) { + return generateUserJWT(userID, svc.authConfig.GeneratedJwsHmacSecret, ttl) +} + +// codeChallengeFromQuery returns the S256 code challenge of a login request, if any +func codeChallengeFromQuery(q url.Values) (string, error) { + challenge := q.Get(oauth.QueryParamCodeChallenge) + if challenge == "" { + return "", nil + } + + if q.Get(oauth.QueryParamCallback) == "" { + return "", errors.New("a code challenge requires a callback") + } + + if q.Get(oauth.QueryParamCodeChallengeMethod) != oauth.CodeChallengeMethodS256 { + return "", fmt.Errorf("unsupported code challenge method, only %s is supported", oauth.CodeChallengeMethodS256) + } + + if !oauth.ValidS256Challenge(challenge) { + return "", errors.New("invalid code challenge") + } + + return challenge, nil +} + type AuthURLs struct { Login, callback string loginIsOverridden bool @@ -324,14 +443,28 @@ func (h oauthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func loginHandler(svc *AuthService, w http.ResponseWriter, r *http.Request) *oauthResp { // The final destination where the auth token will be pushed to, i.e the CLI. // Rejected upfront so a crafted callback never starts the OIDC dance - callback := r.URL.Query().Get(oauth.QueryParamCallback) + query := r.URL.Query() + callback := query.Get(oauth.QueryParamCallback) if err := callbackAllowed(callback, svc.allowedCallbackOrigins); err != nil { return newOauthResp(http.StatusBadRequest, err, true) } - b := make([]byte, 16) - _, err := rand.Read(b) + if err := returnToAllowed(callback); err != nil { + return newOauthResp(http.StatusBadRequest, err, true) + } + + // A client that sends a code challenge gets a handoff code instead of the token + codeChallenge, err := codeChallengeFromQuery(query) if err != nil { + return newOauthResp(http.StatusBadRequest, err, true) + } + + if err := svc.checkLegacyCallback(callback, codeChallenge); err != nil { + return newOauthResp(http.StatusBadRequest, err, true) + } + + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { return newOauthResp(http.StatusInternalServerError, fmt.Errorf("failed to generate random string: %w", err), false) } @@ -340,16 +473,17 @@ func loginHandler(svc *AuthService, w http.ResponseWriter, r *http.Request) *oau svc.setOauthCookie(w, cookieOauthStateName, state) svc.setOauthCookie(w, cookieCallback, callback) + svc.setOauthCookie(w, cookieCodeChallenge, codeChallenge) // Wether the token should be short lived or not - svc.setOauthCookie(w, cookieLongLived, r.URL.Query().Get(oauth.QueryParamLongLived)) + svc.setOauthCookie(w, cookieLongLived, query.Get(oauth.QueryParamLongLived)) authorizationURI := svc.authenticator.AuthCodeURL(state) // Add the connection parameter to the authorization URL if needed // ?connection is useful for example in auth0 to know which connection to use // https://auth0.com/docs/api/authentication#login - connectionStr := r.URL.Query().Get(oauth.QueryParamAuth0Connection) + connectionStr := query.Get(oauth.QueryParamAuth0Connection) if connectionStr != "" { uri, err := url.Parse(authorizationURI) if err != nil { @@ -362,7 +496,7 @@ func loginHandler(svc *AuthService, w http.ResponseWriter, r *http.Request) *oau } http.Redirect(w, r, authorizationURI, http.StatusFound) - return newOauthResp(http.StatusTemporaryRedirect, nil, false) + return nil } // Extract custom claims @@ -435,6 +569,12 @@ func callbackHandler(svc *AuthService, w http.ResponseWriter, r *http.Request) * return newOauthResp(http.StatusInternalServerError, fmt.Errorf("failed to accept pending invitations: %w", err), false) } + return svc.deliverLogin(ctx, w, r, u.ID) +} + +// deliverLogin hands the session of the user over to the client that started the login: a +// handoff code when it sent a code challenge, otherwise the token itself +func (svc *AuthService) deliverLogin(ctx context.Context, w http.ResponseWriter, r *http.Request, userID string) *oauthResp { // Set the expiration expiration := shortLivedDuration longLived, err := r.Cookie(cookieLongLived) @@ -446,12 +586,6 @@ func callbackHandler(svc *AuthService, w http.ResponseWriter, r *http.Request) * expiration = longLivedDuration } - // Generate user token - userToken, err := generateUserJWT(u.ID, svc.authConfig.GeneratedJwsHmacSecret, expiration) - if err != nil { - return newOauthResp(http.StatusInternalServerError, fmt.Errorf("failed to generate user token: %w", err), false) - } - // Either redirect or render the token if fallback is specified // Callback URL from the cookie callbackURLFromCookie, err := r.Cookie(cookieCallback) @@ -463,36 +597,74 @@ func callbackHandler(svc *AuthService, w http.ResponseWriter, r *http.Request) * // There is no callback, render the token in an HTML page with a copy button if callbackValue == "" { + userToken, err := svc.userToken(userID, expiration) + if err != nil { + return newOauthResp(http.StatusInternalServerError, fmt.Errorf("failed to generate user token: %w", err), false) + } + if err := renderTokenPage(w, userToken); err != nil { return newOauthResp(http.StatusInternalServerError, err, false) } - return newOauthResp(http.StatusOK, nil, false) + return nil } - // Redirect to the callback URL. The cookie was validated at login time, re-check it here - // so a stale or tampered cookie can't turn this into an open redirect either + // Redirect to the callback URL. The cookies were validated at login time, re-check them here + // so a stale or tampered cookie, or a login started before an upgrade, can't turn this into + // an open redirect or skip the legacy setting if err := callbackAllowed(callbackValue, svc.allowedCallbackOrigins); err != nil { return newOauthResp(http.StatusBadRequest, err, true) } - callbackURL, err := crafCallbackURL(callbackValue, userToken) + if err := returnToAllowed(callbackValue); err != nil { + return newOauthResp(http.StatusBadRequest, err, true) + } + + // A login started before an upgrade has no challenge cookie, it is a legacy login + var codeChallenge string + if c, err := r.Cookie(cookieCodeChallenge); err == nil { + codeChallenge = c.Value + } + + if err := svc.checkLegacyCallback(callbackValue, codeChallenge); err != nil { + return newOauthResp(http.StatusBadRequest, err, true) + } + + param, value := oauth.QueryParamHandoff, "" + if codeChallenge != "" { + if value, err = svc.loginHandoffs.Issue(ctx, userID, expiration, codeChallenge, callbackValue); err != nil { + return newOauthResp(http.StatusInternalServerError, fmt.Errorf("failed to issue login handoff code: %w", err), false) + } + } else { + param = oauth.QueryParamLegacyToken + if value, err = svc.userToken(userID, expiration); err != nil { + return newOauthResp(http.StatusInternalServerError, fmt.Errorf("failed to generate user token: %w", err), false) + } + + if isLegacyDelivery(callbackValue, codeChallenge) { + legacyCallbackDeliveries.Inc() + svc.log.WithContext(ctx).Warnw("msg", "login delivered the token in the callback URL, the client does not use the code exchange", + "callback_origin", strings.Join(originsOf(callbackValue), "")) + } + } + + callbackURL, err := crafCallbackURL(callbackValue, param, value) if err != nil { return newOauthResp(http.StatusInternalServerError, fmt.Errorf("failed to craft callback URL: %w", err), false) } setTokenLeakHeaders(w) http.Redirect(w, r, callbackURL, http.StatusFound) - return newOauthResp(http.StatusTemporaryRedirect, nil, false) + return nil } -func crafCallbackURL(callback, userToken string) (string, error) { +func crafCallbackURL(callback, param, value string) (string, error) { callbackURL, err := url.Parse(callback) if err != nil { return "", fmt.Errorf("invalid callback URL: %w", err) } q := callbackURL.Query() - q.Set("t", userToken) + q.Set(param, value) callbackURL.RawQuery = q.Encode() return callbackURL.String(), nil @@ -593,6 +765,29 @@ func generateAndLogDevUser(userUC *biz.UserUseCase, log *log.Helper, authConfig return nil } +// ExchangeHandoff trades a login handoff code for the session token. Every failure returns the +// same error, the specific reason only goes to the server log +func (svc *AuthService) ExchangeHandoff(ctx context.Context, req *pb.AuthServiceExchangeHandoffRequest) (*pb.AuthServiceExchangeHandoffResponse, error) { + h, err := svc.loginHandoffs.Exchange(ctx, req.GetHandoff(), req.GetCodeVerifier(), req.GetCallback()) + if err != nil { + var exchangeErr biz.ErrLoginHandoffExchange + if errors.As(err, &exchangeErr) { + svc.log.WithContext(ctx).Warnw("msg", "login handoff exchange failed", "reason", exchangeErr.Reason) + } else { + svc.log.WithContext(ctx).Errorw("msg", "login handoff exchange failed unexpectedly", "error", err) + } + + return nil, errLoginHandoffExchange + } + + token, err := svc.userToken(h.UserID.String(), h.TokenTTL) + if err != nil { + return nil, sl.LogAndMaskErr(ctx, fmt.Errorf("generating user token: %w", err), svc.log) + } + + return &pb.AuthServiceExchangeHandoffResponse{Token: token}, nil +} + // DeleteAccount deletes an account func (svc *AuthService) DeleteAccount(ctx context.Context, _ *pb.AuthServiceDeleteAccountRequest) (*pb.AuthServiceDeleteAccountResponse, error) { user, err := requireCurrentUser(ctx) diff --git a/app/controlplane/internal/service/auth_handoff_test.go b/app/controlplane/internal/service/auth_handoff_test.go new file mode 100644 index 000000000..646c59f8a --- /dev/null +++ b/app/controlplane/internal/service/auth_handoff_test.go @@ -0,0 +1,482 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// 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 service + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1" + authenticator "github.com/chainloop-dev/chainloop/app/controlplane/internal/oidcauthenticator" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/jwt/user" + "github.com/chainloop-dev/chainloop/internal/oauth" + kerrors "github.com/go-kratos/kratos/v2/errors" + "github.com/go-kratos/kratos/v2/log" + jwt "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + "google.golang.org/protobuf/proto" +) + +const ( + testHMACSecret = "test-secret" + testCLIBack = "http://localhost:1234/auth/callback" + testAppBack = "https://app.chainloop.dev/login/callback" + // parses with no host, but browsers resolve it off-origin + testMultiSlashBack = "////evil.example/cb" +) + +var testChallenge = oauth2.S256ChallengeFromVerifier("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") + +type fakeLoginHandoffs struct { + issued struct { + userID, challenge, callback string + ttl time.Duration + } + exchanged *biz.LoginHandoff + exchangeErr error +} + +func (f *fakeLoginHandoffs) Issue(_ context.Context, userID string, ttl time.Duration, challenge, callback string) (string, error) { + f.issued.userID, f.issued.ttl, f.issued.challenge, f.issued.callback = userID, ttl, challenge, callback + return "the-handoff-code", nil +} + +func (f *fakeLoginHandoffs) Exchange(_ context.Context, _, _, _ string) (*biz.LoginHandoff, error) { + return f.exchanged, f.exchangeErr +} + +func newHandoffTestService(allowLegacy *bool, handoffs loginHandoffs, logs *bytes.Buffer) *AuthService { + if logs == nil { + logs = &bytes.Buffer{} + } + + return &AuthService{ + service: &service{log: log.NewHelper(log.NewStdLogger(logs))}, + authConfig: &conf.Auth{GeneratedJwsHmacSecret: testHMACSecret, AllowLegacyLoginCallback: allowLegacy}, + allowedCallbackOrigins: originsOf("https://app.chainloop.dev"), + loginHandoffs: handoffs, + // The login handler only needs the authorization endpoint to redirect + authenticator: &authenticator.OIDC{Config: oauth2.Config{Endpoint: oauth2.Endpoint{AuthURL: "https://idp.example/auth"}}}, + } +} + +func loginURL(params map[string]string) string { + q := url.Values{} + for k, v := range params { + q.Set(k, v) + } + return "/auth/login?" + q.Encode() +} + +func TestReturnToAllowed(t *testing.T) { + testCases := []struct { + name string + returnTo string + wantErr bool + }{ + {"no returnTo", "", false}, + {"relative path", "/projects/foo?tab=1", false}, + {"root", "/", false}, + {"absolute URL", "https://evil.example/collect", true}, + {"absolute URL same origin", "https://app.chainloop.dev/projects", true}, + {"scheme-relative", "//evil.example", true}, + {"backslash", `/\evil.example`, true}, + {"backslash only", `\\evil.example`, true}, + {"tab between slashes", "/\t/evil.example", true}, + {"newline between slashes", "/\n/evil.example", true}, + {"no leading slash", "projects", true}, + {"javascript scheme", "javascript:alert(1)", true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + callback := testAppBack + if tc.returnTo != "" { + callback += "?" + url.Values{oauth.QueryParamReturnTo: {tc.returnTo}}.Encode() + } + + err := returnToAllowed(callback) + if tc.wantErr { + assert.ErrorIs(t, err, errInvalidReturnTo) + return + } + assert.NoError(t, err) + }) + } + + t.Run("any of several values", func(t *testing.T) { + assert.Error(t, returnToAllowed(testAppBack+"?returnTo=%2Fok&returnTo=%2F%2Fevil.example")) + }) +} + +func TestLegacyCallbackAllowed(t *testing.T) { + assert.True(t, LegacyCallbackAllowed(nil)) + assert.True(t, LegacyCallbackAllowed(&conf.Auth{})) + assert.True(t, LegacyCallbackAllowed(&conf.Auth{AllowLegacyLoginCallback: proto.Bool(true)})) + assert.False(t, LegacyCallbackAllowed(&conf.Auth{AllowLegacyLoginCallback: proto.Bool(false)})) +} + +func TestLoginHandlerCodeChallenge(t *testing.T) { + testCases := []struct { + name string + params map[string]string + allowLegacy *bool + wantCode int + wantErr string + wantChallenge string + }{ + { + name: "code exchange", + params: map[string]string{oauth.QueryParamCallback: testCLIBack, oauth.QueryParamCodeChallenge: testChallenge, oauth.QueryParamCodeChallengeMethod: oauth.CodeChallengeMethodS256}, + wantCode: http.StatusTemporaryRedirect, + wantChallenge: testChallenge, + }, + { + name: "code exchange with legacy denied", + params: map[string]string{oauth.QueryParamCallback: testCLIBack, oauth.QueryParamCodeChallenge: testChallenge, oauth.QueryParamCodeChallengeMethod: oauth.CodeChallengeMethodS256}, + allowLegacy: proto.Bool(false), + wantCode: http.StatusTemporaryRedirect, + wantChallenge: testChallenge, + }, + { + name: "legacy login allowed by default", + params: map[string]string{oauth.QueryParamCallback: testCLIBack}, + wantCode: http.StatusTemporaryRedirect, + }, + { + name: "legacy login denied", + params: map[string]string{oauth.QueryParamCallback: testCLIBack}, + wantCode: http.StatusBadRequest, allowLegacy: proto.Bool(false), + wantErr: "upgrade", + }, + { + name: "headless login not affected by the legacy setting", + params: map[string]string{oauth.QueryParamCallback: ""}, + allowLegacy: proto.Bool(false), + wantCode: http.StatusTemporaryRedirect, + }, + { + name: "download flow not affected by the legacy setting", + params: map[string]string{oauth.QueryParamCallback: "/download/sha256:abc"}, + allowLegacy: proto.Bool(false), + wantCode: http.StatusTemporaryRedirect, + }, + { + // It parses with no host, but browsers resolve it off-origin + name: "multi-slash callback posing as the download flow", + params: map[string]string{oauth.QueryParamCallback: testMultiSlashBack}, + wantCode: http.StatusBadRequest, + wantErr: errInvalidCallback.Error(), + }, + { + name: "plain challenge method", + params: map[string]string{oauth.QueryParamCallback: testCLIBack, oauth.QueryParamCodeChallenge: testChallenge, oauth.QueryParamCodeChallengeMethod: "plain"}, + wantCode: http.StatusBadRequest, + wantErr: "unsupported code challenge method", + }, + { + name: "missing challenge method", + params: map[string]string{oauth.QueryParamCallback: testCLIBack, oauth.QueryParamCodeChallenge: testChallenge}, + wantCode: http.StatusBadRequest, + }, + { + name: "malformed challenge", + params: map[string]string{oauth.QueryParamCallback: testCLIBack, oauth.QueryParamCodeChallenge: "short", oauth.QueryParamCodeChallengeMethod: oauth.CodeChallengeMethodS256}, + wantCode: http.StatusBadRequest, + wantErr: "invalid code challenge", + }, + { + name: "challenge without callback", + params: map[string]string{oauth.QueryParamCodeChallenge: testChallenge, oauth.QueryParamCodeChallengeMethod: oauth.CodeChallengeMethodS256}, + wantCode: http.StatusBadRequest, + }, + { + name: "absolute returnTo", + params: map[string]string{oauth.QueryParamCallback: testAppBack + "?returnTo=https%3A%2F%2Fevil.example"}, + wantCode: http.StatusBadRequest, + wantErr: errInvalidReturnTo.Error(), + }, + { + name: "relative returnTo", + params: map[string]string{oauth.QueryParamCallback: testAppBack + "?returnTo=%2Fprojects"}, + wantCode: http.StatusTemporaryRedirect, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + svc := newHandoffTestService(tc.allowLegacy, nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, loginURL(tc.params), nil) + + resp := loginHandler(svc, w, r) + if tc.wantCode != http.StatusBadRequest { + // The handler answered the request with the redirect + assert.Nil(t, resp) + assert.Equal(t, http.StatusFound, w.Code) + assert.Equal(t, tc.wantChallenge, cookieValue(w, cookieCodeChallenge)) + return + } + + require.NotNil(t, resp) + assert.Equal(t, tc.wantCode, resp.code) + assert.Contains(t, resp.ErrorMessage(context.Background(), svc.log), tc.wantErr) + assert.Empty(t, w.Result().Cookies()) + }) + } +} + +func cookieValue(w *httptest.ResponseRecorder, name string) string { + for _, c := range w.Result().Cookies() { + if c.Name == name { + return c.Value + } + } + return "" +} + +func TestDeliverLogin(t *testing.T) { + userID := uuid.NewString() + + testCases := []struct { + name string + callback string + challenge string + allowLegacy *bool + wantCode int + wantHandoff bool + wantToken bool + wantLegacyInc float64 + }{ + {name: "code exchange", callback: testCLIBack, challenge: testChallenge, wantCode: http.StatusTemporaryRedirect, wantHandoff: true}, + {name: "code exchange with legacy denied", callback: testCLIBack, challenge: testChallenge, allowLegacy: proto.Bool(false), wantCode: http.StatusTemporaryRedirect, wantHandoff: true}, + {name: "legacy login", callback: testCLIBack, wantCode: http.StatusTemporaryRedirect, wantToken: true, wantLegacyInc: 1}, + {name: "legacy login denied", callback: testCLIBack, allowLegacy: proto.Bool(false), wantCode: http.StatusBadRequest}, + {name: "download flow", callback: "/download/sha256:abc", allowLegacy: proto.Bool(false), wantCode: http.StatusTemporaryRedirect, wantToken: true}, + {name: "multi-slash callback cookie", callback: testMultiSlashBack, allowLegacy: proto.Bool(false), wantCode: http.StatusBadRequest}, + {name: "multi-slash callback cookie with legacy allowed", callback: testMultiSlashBack, wantCode: http.StatusBadRequest}, + {name: "headless", callback: "", wantCode: http.StatusOK}, + {name: "off-origin returnTo", callback: testAppBack + "?returnTo=%2F%2Fevil.example", challenge: testChallenge, wantCode: http.StatusBadRequest}, + {name: "foreign callback", callback: "https://evil.example/cb", challenge: testChallenge, wantCode: http.StatusBadRequest}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + handoffs := &fakeLoginHandoffs{} + svc := newHandoffTestService(tc.allowLegacy, handoffs, nil) + + r := httptest.NewRequest(http.MethodGet, "/auth/callback", nil) + r.AddCookie(&http.Cookie{Name: cookieLongLived, Value: "true"}) //nolint:gosec // cookies of a test request + r.AddCookie(&http.Cookie{Name: cookieCallback, Value: tc.callback}) //nolint:gosec // cookies of a test request + if tc.challenge != "" { + r.AddCookie(&http.Cookie{Name: cookieCodeChallenge, Value: tc.challenge}) //nolint:gosec // cookies of a test request + } + w := httptest.NewRecorder() + + before := testutil.ToFloat64(legacyCallbackDeliveries) + resp := svc.deliverLogin(context.Background(), w, r, userID) + assert.Equal(t, tc.wantLegacyInc, testutil.ToFloat64(legacyCallbackDeliveries)-before) + + // The handler answers a delivered login itself and returns nothing + if tc.wantCode == http.StatusOK { + assert.Nil(t, resp) + assert.Equal(t, http.StatusOK, w.Code) + return + } + + if tc.wantCode != http.StatusTemporaryRedirect { + require.NotNil(t, resp) + assert.Equal(t, tc.wantCode, resp.code) + assert.Empty(t, w.Header().Get("Location")) + return + } + + assert.Nil(t, resp) + assert.Equal(t, http.StatusFound, w.Code) + + location, err := url.Parse(w.Header().Get("Location")) + require.NoError(t, err) + q := location.Query() + + if tc.wantHandoff { + assert.Equal(t, "the-handoff-code", q.Get(oauth.QueryParamHandoff)) + assert.Empty(t, q.Get(oauth.QueryParamLegacyToken)) + assert.Equal(t, userID, handoffs.issued.userID) + assert.Equal(t, longLivedDuration, handoffs.issued.ttl) + assert.Equal(t, tc.challenge, handoffs.issued.challenge) + assert.Equal(t, tc.callback, handoffs.issued.callback) + } + + if tc.wantToken { + assert.NotEmpty(t, q.Get(oauth.QueryParamLegacyToken)) + assert.Empty(t, q.Get(oauth.QueryParamHandoff)) + } + }) + } +} + +// headerCounter counts the WriteHeader calls, a second one is what net/http logs as "superfluous" +type headerCounter struct { + *httptest.ResponseRecorder + writes int +} + +func (h *headerCounter) WriteHeader(code int) { + h.writes++ + h.ResponseRecorder.WriteHeader(code) +} + +// A handler that already answered the request must not get a second response from ServeHTTP +func TestOauthHandlerAnswersOnce(t *testing.T) { + loginRequest := func() *http.Request { + return httptest.NewRequest(http.MethodGet, loginURL(map[string]string{oauth.QueryParamCallback: testCLIBack}), nil) + } + + deliverRequest := func(callback, challenge string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/auth/callback", nil) + r.AddCookie(&http.Cookie{Name: cookieLongLived, Value: "true"}) //nolint:gosec // cookies of a test request + r.AddCookie(&http.Cookie{Name: cookieCallback, Value: callback}) //nolint:gosec // cookies of a test request + if challenge != "" { + r.AddCookie(&http.Cookie{Name: cookieCodeChallenge, Value: challenge}) //nolint:gosec // cookies of a test request + } + return r + } + + deliver := func(svc *AuthService, w http.ResponseWriter, r *http.Request) *oauthResp { + return svc.deliverLogin(context.Background(), w, r, uuid.NewString()) + } + + testCases := []struct { + name string + handler func(*AuthService, http.ResponseWriter, *http.Request) *oauthResp + request *http.Request + wantCode int + }{ + {name: "login redirect", handler: loginHandler, request: loginRequest(), wantCode: http.StatusFound}, + {name: "handoff redirect", handler: deliver, request: deliverRequest(testCLIBack, testChallenge), wantCode: http.StatusFound}, + {name: "legacy token redirect", handler: deliver, request: deliverRequest(testCLIBack, ""), wantCode: http.StatusFound}, + {name: "headless token page", handler: deliver, request: deliverRequest("", ""), wantCode: http.StatusOK}, + {name: "rejected callback", handler: deliver, request: deliverRequest(testMultiSlashBack, ""), wantCode: http.StatusBadRequest}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + svc := newHandoffTestService(nil, &fakeLoginHandoffs{}, nil) + w := &headerCounter{ResponseRecorder: httptest.NewRecorder()} + + oauthHandler{tc.handler, svc}.ServeHTTP(w, tc.request) + + assert.Equal(t, tc.wantCode, w.Code) + // A page body written with no explicit header counts as none + assert.LessOrEqual(t, w.writes, 1) + }) + } +} + +func TestExchangeHandoff(t *testing.T) { + userID := uuid.New() + req := &pb.AuthServiceExchangeHandoffRequest{Handoff: "code", CodeVerifier: "verifier", Callback: testCLIBack} + + t.Run("success", func(t *testing.T) { + svc := newHandoffTestService(nil, &fakeLoginHandoffs{exchanged: &biz.LoginHandoff{UserID: userID, TokenTTL: time.Hour}}, nil) + resp, err := svc.ExchangeHandoff(context.Background(), req) + require.NoError(t, err) + + claims := &user.CustomClaims{} + _, err = jwt.ParseWithClaims(resp.GetToken(), claims, func(_ *jwt.Token) (any, error) { + return []byte(testHMACSecret), nil + }) + require.NoError(t, err) + assert.Equal(t, userID.String(), claims.UserID) + assert.WithinDuration(t, time.Now().Add(time.Hour), claims.ExpiresAt.Time, time.Minute) + }) + + reasons := []string{ + biz.LoginHandoffReasonUnknown, + biz.LoginHandoffReasonExpired, + biz.LoginHandoffReasonAlreadyUsed, + biz.LoginHandoffReasonWrongVerifier, + biz.LoginHandoffReasonWrongCallback, + } + + for _, reason := range reasons { + t.Run(reason, func(t *testing.T) { + var logs bytes.Buffer + svc := newHandoffTestService(nil, &fakeLoginHandoffs{exchangeErr: biz.ErrLoginHandoffExchange{Reason: reason}}, &logs) + + _, err := svc.ExchangeHandoff(context.Background(), req) + // Same error for every reason, the reason only goes to the server log + assert.Equal(t, errLoginHandoffExchange, err) + assert.True(t, kerrors.IsUnauthorized(err)) + assert.NotContains(t, err.Error(), reason) + assert.Contains(t, logs.String(), reason) + }) + } + + unexpected := []struct { + name string + err error + }{ + {"repository error", errors.New("db down")}, + {"cancelled", context.Canceled}, + } + + for _, tc := range unexpected { + t.Run(tc.name, func(t *testing.T) { + var logs bytes.Buffer + svc := newHandoffTestService(nil, &fakeLoginHandoffs{exchangeErr: tc.err}, &logs) + + _, err := svc.ExchangeHandoff(context.Background(), req) + // Unexpected failures are indistinguishable from the rest for the client, the cause only goes to the server log + assert.Equal(t, errLoginHandoffExchange, err) + assert.Contains(t, logs.String(), tc.err.Error()) + }) + } +} + +func TestInfozLoginFlags(t *testing.T) { + testCases := []struct { + name string + allowLegacy *bool + want bool + }{ + {"legacy allowed by default", nil, true}, + {"legacy allowed", proto.Bool(true), true}, + {"legacy denied", proto.Bool(false), false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + svc := NewStatusService("http://login", "v1", nil, &conf.Bootstrap{Auth: &conf.Auth{AllowLegacyLoginCallback: tc.allowLegacy}}) + resp, err := svc.Infoz(context.Background(), &pb.InfozRequest{}) + require.NoError(t, err) + assert.True(t, resp.GetLoginCodeExchangeSupported()) + assert.Equal(t, tc.want, resp.GetLoginLegacyCallbackAllowed()) + }) + } +} diff --git a/app/controlplane/internal/service/auth_test.go b/app/controlplane/internal/service/auth_test.go index 95117da1b..60121ff0e 100644 --- a/app/controlplane/internal/service/auth_test.go +++ b/app/controlplane/internal/service/auth_test.go @@ -146,6 +146,10 @@ func TestCallbackAllowed(t *testing.T) { {name: "dashboard origin, browser sent a different case", callback: "https://APP.chainloop.dev/login/callback"}, {name: "third party origin", callback: "https://evil.example/collect", wantErr: true}, {name: "protocol relative", callback: "//evil.example/collect", wantErr: true}, + {name: "many leading slashes parse with no host", callback: "////evil.example/collect", wantErr: true}, + {name: "triple slash", callback: "///evil.example/collect", wantErr: true}, + {name: "tab between slashes", callback: "/\t/evil.example/collect", wantErr: true}, + {name: "relative without a leading slash", callback: "evil.example/collect", wantErr: true}, {name: "backslash escaping a relative path", callback: "/\\evil.example/collect", wantErr: true}, {name: "non http scheme", callback: "javascript:alert(1)", wantErr: true}, {name: "loopback lookalike host", callback: "http://localhost.evil.example/collect", wantErr: true}, diff --git a/app/controlplane/internal/service/status.go b/app/controlplane/internal/service/status.go index 95ef63113..08215804e 100644 --- a/app/controlplane/internal/service/status.go +++ b/app/controlplane/internal/service/status.go @@ -1,5 +1,5 @@ // -// Copyright 2023-2025 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -57,6 +57,9 @@ func (s *StatusService) Infoz(_ context.Context, _ *pb.InfozRequest) (*pb.InfozR Version: s.version, ChartVersion: os.Getenv("CHART_VERSION"), RestrictedOrgCreation: s.bootstrap.RestrictOrgCreation, + // Clients send a code challenge only when the server reports the exchange + LoginCodeExchangeSupported: true, + LoginLegacyCallbackAllowed: LegacyCallbackAllowed(s.bootstrap.GetAuth()), }, nil } diff --git a/app/controlplane/pkg/authz/authz_consistency_test.go b/app/controlplane/pkg/authz/authz_consistency_test.go index 40577ba88..a1c78d916 100644 --- a/app/controlplane/pkg/authz/authz_consistency_test.go +++ b/app/controlplane/pkg/authz/authz_consistency_test.go @@ -66,10 +66,13 @@ var authzExemptProcedures = map[string]struct{}{ "/controlplane.v1.AttestationStateService/Read": {}, "/controlplane.v1.AttestationStateService/Reset": {}, "/controlplane.v1.AttestationStateService/Save": {}, - "/controlplane.v1.SigningService/GenerateSigningCert": {}, - "/controlplane.v1.SigningService/GetTrustedRoot": {}, - "/controlplane.v1.StatusService/Infoz": {}, - "/controlplane.v1.StatusService/Statusz": {}, + // AuthService/ExchangeHandoff — unauthenticated, skipped by requireCurrentUserMatcher. The + // handoff code and its verifier are the credentials. + "/controlplane.v1.AuthService/ExchangeHandoff": {}, + "/controlplane.v1.SigningService/GenerateSigningCert": {}, + "/controlplane.v1.SigningService/GetTrustedRoot": {}, + "/controlplane.v1.StatusService/Infoz": {}, + "/controlplane.v1.StatusService/Statusz": {}, } // adminOnlyProcedures lists controlplane.v1 procedures that reach the authorization middleware but diff --git a/app/controlplane/pkg/biz/biz.go b/app/controlplane/pkg/biz/biz.go index ab4d6a812..a299fdbf5 100644 --- a/app/controlplane/pkg/biz/biz.go +++ b/app/controlplane/pkg/biz/biz.go @@ -59,6 +59,7 @@ var ProviderSet = wire.NewSet( NewGroupUseCase, NewCASBackendChecker, NewAPITokenStaleRevoker, + NewLoginHandoffUseCase, NewAuthzUseCase, wire.Bind(new(PromObservable), new(*PrometheusUseCase)), wire.Struct(new(NewIntegrationUseCaseOpts), "*"), diff --git a/app/controlplane/pkg/biz/export_test.go b/app/controlplane/pkg/biz/export_test.go new file mode 100644 index 000000000..9f2d6af98 --- /dev/null +++ b/app/controlplane/pkg/biz/export_test.go @@ -0,0 +1,23 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// 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 biz + +import "time" + +// SetNow overrides the clock of the use case in tests +func (uc *LoginHandoffUseCase) SetNow(now func() time.Time) { + uc.now = now +} diff --git a/app/controlplane/pkg/biz/loginhandoff.go b/app/controlplane/pkg/biz/loginhandoff.go new file mode 100644 index 000000000..1612cf886 --- /dev/null +++ b/app/controlplane/pkg/biz/loginhandoff.go @@ -0,0 +1,194 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// 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 biz + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/chainloop-dev/chainloop/internal/oauth" + "github.com/chainloop-dev/chainloop/pkg/otelx" + "github.com/go-kratos/kratos/v2/log" + "github.com/google/uuid" + "golang.org/x/oauth2" +) + +var loginHandoffTracer = otelx.Tracer("chainloop-controlplane", "biz/loginhandoff") + +const ( + // LoginHandoffTTL is how long a handoff code can be exchanged after it is issued + LoginHandoffTTL = 60 * time.Second + loginHandoffSweepPeriod = 10 * time.Minute +) + +// Reasons why a handoff exchange fails. They are meant for the server log only, +// the client always gets the same error +const ( + LoginHandoffReasonUnknown = "unknown code" + LoginHandoffReasonExpired = "expired" + LoginHandoffReasonAlreadyUsed = "already used" + LoginHandoffReasonWrongVerifier = "wrong verifier" + LoginHandoffReasonWrongCallback = "wrong callback" +) + +// LoginHandoff is a single-use code a login delivers to the client callback instead of the session token +type LoginHandoff struct { + UserID uuid.UUID + // Lifetime of the session token created at exchange time + TokenTTL time.Duration + CodeChallenge string + Callback string + ExpiresAt time.Time +} + +type LoginHandoffRepo interface { + Create(ctx context.Context, codeHash string, h *LoginHandoff) error + // Consume marks the code used in one atomic step. It returns the handoff and whether this + // call is the one that consumed it. It returns ErrNotFound if there is no such code + Consume(ctx context.Context, codeHash string, at time.Time) (*LoginHandoff, bool, error) + // DeleteExpired removes the codes that expired before the given time. A used code is + // inert, it goes when it expires + DeleteExpired(ctx context.Context, before time.Time) (int, error) +} + +// ErrLoginHandoffExchange is returned by a failed exchange. Reason is for the server log only +type ErrLoginHandoffExchange struct { + Reason string +} + +func (e ErrLoginHandoffExchange) Error() string { + return "login handoff exchange failed: " + e.Reason +} + +type LoginHandoffUseCase struct { + repo LoginHandoffRepo + logger *log.Helper + // overridable in tests + now func() time.Time +} + +func NewLoginHandoffUseCase(repo LoginHandoffRepo, logger log.Logger) *LoginHandoffUseCase { + return &LoginHandoffUseCase{ + repo: repo, + logger: log.NewHelper(log.With(logger, "component", "biz/LoginHandoff")), + now: time.Now, + } +} + +// Issue stores a new handoff code for the given user and returns it. Only its hash is stored +func (uc *LoginHandoffUseCase) Issue(ctx context.Context, userID string, tokenTTL time.Duration, codeChallenge, callback string) (string, error) { + ctx, span := otelx.Start(ctx, loginHandoffTracer, "LoginHandoffUseCase.Issue") + defer span.End() + + userUUID, err := uuid.Parse(userID) + if err != nil { + return "", NewErrInvalidUUID(err) + } + + if !oauth.ValidS256Challenge(codeChallenge) { + return "", NewErrValidationStr("invalid code challenge") + } + + if callback == "" { + return "", NewErrValidationStr("callback is required") + } + + // 256 bits from crypto/rand, base64url encoded + code := oauth2.GenerateVerifier() + if err := uc.repo.Create(ctx, hashLoginHandoffCode(code), &LoginHandoff{ + UserID: userUUID, + TokenTTL: tokenTTL, + CodeChallenge: codeChallenge, + Callback: callback, + ExpiresAt: uc.now().Add(LoginHandoffTTL), + }); err != nil { + return "", fmt.Errorf("storing handoff code: %w", err) + } + + return code, nil +} + +// Exchange consumes the handoff code and returns it when the verifier matches the challenge and +// the callback matches the one the login was started with. Any failure is an ErrLoginHandoffExchange +func (uc *LoginHandoffUseCase) Exchange(ctx context.Context, code, codeVerifier, callback string) (*LoginHandoff, error) { + ctx, span := otelx.Start(ctx, loginHandoffTracer, "LoginHandoffUseCase.Exchange") + defer span.End() + + now := uc.now() + // The code is marked used before any other check, so a wrong guess of the verifier burns it + h, consumed, err := uc.repo.Consume(ctx, hashLoginHandoffCode(code), now) + if err != nil { + if IsNotFound(err) { + return nil, ErrLoginHandoffExchange{Reason: LoginHandoffReasonUnknown} + } + return nil, fmt.Errorf("consuming handoff code: %w", err) + } + + switch { + case !consumed: + return nil, ErrLoginHandoffExchange{Reason: LoginHandoffReasonAlreadyUsed} + case !now.Before(h.ExpiresAt): + return nil, ErrLoginHandoffExchange{Reason: LoginHandoffReasonExpired} + case !oauth.VerifyS256(codeVerifier, h.CodeChallenge): + return nil, ErrLoginHandoffExchange{Reason: LoginHandoffReasonWrongVerifier} + case callback != h.Callback: + return nil, ErrLoginHandoffExchange{Reason: LoginHandoffReasonWrongCallback} + } + + return h, nil +} + +// Sweep deletes the expired handoff codes +func (uc *LoginHandoffUseCase) Sweep(ctx context.Context) error { + ctx, span := otelx.Start(ctx, loginHandoffTracer, "LoginHandoffUseCase.Sweep") + defer span.End() + + n, err := uc.repo.DeleteExpired(ctx, uc.now()) + if err != nil { + return fmt.Errorf("deleting expired login handoff codes: %w", err) + } + + if n > 0 { + uc.logger.Debugw("msg", "deleted expired login handoff codes", "count", n) + } + + return nil +} + +// StartSweeper runs Sweep periodically until the context is done +func (uc *LoginHandoffUseCase) StartSweeper(ctx context.Context) { + ticker := time.NewTicker(loginHandoffSweepPeriod) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := uc.Sweep(ctx); err != nil { + uc.logger.Errorf("login handoff sweep failed: %v", err) + } + } + } +} + +func hashLoginHandoffCode(code string) string { + sum := sha256.Sum256([]byte(code)) + return hex.EncodeToString(sum[:]) +} diff --git a/app/controlplane/pkg/biz/loginhandoff_integration_test.go b/app/controlplane/pkg/biz/loginhandoff_integration_test.go new file mode 100644 index 000000000..b9b8d1a5f --- /dev/null +++ b/app/controlplane/pkg/biz/loginhandoff_integration_test.go @@ -0,0 +1,215 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// 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 biz_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" + "github.com/stretchr/testify/suite" + "golang.org/x/oauth2" +) + +const handoffTestCallback = "http://localhost:1234/auth/callback" + +func TestLoginHandoffUseCase(t *testing.T) { + suite.Run(t, new(loginHandoffTestSuite)) +} + +type loginHandoffTestSuite struct { + testhelpers.UseCasesEachTestSuite + user *biz.User + verifier string + challenge string +} + +func (s *loginHandoffTestSuite) SetupTest() { + var err error + s.TestingUseCases = testhelpers.NewTestingUseCases(s.T()) + s.user, err = s.User.UpsertByEmail(context.Background(), "handoff@test.com", nil) + s.Require().NoError(err) + + s.verifier = oauth2.GenerateVerifier() + s.challenge = oauth2.S256ChallengeFromVerifier(s.verifier) +} + +func (s *loginHandoffTestSuite) issue() string { + code, err := s.LoginHandoff.Issue(context.Background(), s.user.ID, time.Hour, s.challenge, handoffTestCallback) + s.Require().NoError(err) + return code +} + +func (s *loginHandoffTestSuite) requireReason(err error, reason string) { + var exchangeErr biz.ErrLoginHandoffExchange + s.Require().True(errors.As(err, &exchangeErr), "unexpected error %v", err) + s.Equal(reason, exchangeErr.Reason) +} + +func (s *loginHandoffTestSuite) TestIssueValidation() { + ctx := context.Background() + testCases := []struct { + name string + userID string + challenge string + callback string + }{ + {"invalid user", "not-a-uuid", s.challenge, handoffTestCallback}, + {"invalid challenge", s.user.ID, "plain", handoffTestCallback}, + {"missing callback", s.user.ID, s.challenge, ""}, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + _, err := s.LoginHandoff.Issue(ctx, tc.userID, time.Hour, tc.challenge, tc.callback) + s.Error(err) + }) + } +} + +func (s *loginHandoffTestSuite) TestIssueCodeIsRandomAndNotStored() { + code := s.issue() + s.GreaterOrEqual(len(code), 43) + s.NotEqual(code, s.issue()) + + // Only the hash is stored + rows := s.Data.DB.LoginHandoff.Query().AllX(context.Background()) + s.Require().Len(rows, 2) + for _, r := range rows { + s.NotEqual(code, r.CodeHash) + s.Len(r.CodeHash, 64) + } +} + +func (s *loginHandoffTestSuite) TestExchange() { + ctx := context.Background() + h, err := s.LoginHandoff.Exchange(ctx, s.issue(), s.verifier, handoffTestCallback) + s.Require().NoError(err) + s.Equal(s.user.ID, h.UserID.String()) + s.Equal(time.Hour, h.TokenTTL) +} + +func (s *loginHandoffTestSuite) TestExchangeFailures() { + ctx := context.Background() + otherVerifier := oauth2.GenerateVerifier() + + testCases := []struct { + name string + code func() string + verifier string + callback string + // shift of the clock at exchange time + after time.Duration + reason string + }{ + {"unknown code", func() string { return "unknown" }, s.verifier, handoffTestCallback, 0, biz.LoginHandoffReasonUnknown}, + {"expired", s.issue, s.verifier, handoffTestCallback, biz.LoginHandoffTTL, biz.LoginHandoffReasonExpired}, + {"wrong verifier", s.issue, otherVerifier, handoffTestCallback, 0, biz.LoginHandoffReasonWrongVerifier}, + {"challenge as verifier", s.issue, s.challenge, handoffTestCallback, 0, biz.LoginHandoffReasonWrongVerifier}, + {"wrong callback", s.issue, s.verifier, "http://localhost:4321/auth/callback", 0, biz.LoginHandoffReasonWrongCallback}, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + code := tc.code() + s.LoginHandoff.SetNow(func() time.Time { return time.Now().Add(tc.after) }) + defer s.LoginHandoff.SetNow(time.Now) + + _, err := s.LoginHandoff.Exchange(ctx, code, tc.verifier, tc.callback) + s.requireReason(err, tc.reason) + }) + } +} + +func (s *loginHandoffTestSuite) TestExchangeIsSingleUse() { + ctx := context.Background() + code := s.issue() + + _, err := s.LoginHandoff.Exchange(ctx, code, s.verifier, handoffTestCallback) + s.Require().NoError(err) + + _, err = s.LoginHandoff.Exchange(ctx, code, s.verifier, handoffTestCallback) + s.requireReason(err, biz.LoginHandoffReasonAlreadyUsed) +} + +func (s *loginHandoffTestSuite) TestFailedExchangeBurnsTheCode() { + ctx := context.Background() + code := s.issue() + + _, err := s.LoginHandoff.Exchange(ctx, code, "wrong-verifier-wrong-verifier-wrong-verifier", handoffTestCallback) + s.requireReason(err, biz.LoginHandoffReasonWrongVerifier) + + _, err = s.LoginHandoff.Exchange(ctx, code, s.verifier, handoffTestCallback) + s.requireReason(err, biz.LoginHandoffReasonAlreadyUsed) +} + +func (s *loginHandoffTestSuite) TestConcurrentExchangeOnlyOneWins() { + ctx := context.Background() + code := s.issue() + + const workers = 10 + var ( + wg sync.WaitGroup + mu sync.Mutex + successes int + ) + + for range workers { + wg.Go(func() { + _, err := s.LoginHandoff.Exchange(ctx, code, s.verifier, handoffTestCallback) + if err == nil { + mu.Lock() + successes++ + mu.Unlock() + return + } + s.ErrorAs(err, &biz.ErrLoginHandoffExchange{}) + }) + } + + wg.Wait() + s.Equal(1, successes) +} + +func (s *loginHandoffTestSuite) TestSweep() { + ctx := context.Background() + used := s.issue() + _ = s.issue() // pending + _, err := s.LoginHandoff.Exchange(ctx, used, s.verifier, handoffTestCallback) + s.Require().NoError(err) + + // Nothing expired yet + s.Require().NoError(s.LoginHandoff.Sweep(ctx)) + s.Equal(2, s.Data.DB.LoginHandoff.Query().CountX(ctx)) + + // After expiry, both the used and the pending codes go + s.LoginHandoff.SetNow(func() time.Time { return time.Now().Add(biz.LoginHandoffTTL + time.Second) }) + s.Require().NoError(s.LoginHandoff.Sweep(ctx)) + s.Equal(0, s.Data.DB.LoginHandoff.Query().CountX(ctx)) +} + +func (s *loginHandoffTestSuite) TestDeletedUserDeletesCodes() { + ctx := context.Background() + code := s.issue() + s.Require().NoError(s.User.DeleteUser(ctx, s.user.ID)) + + _, err := s.LoginHandoff.Exchange(ctx, code, s.verifier, handoffTestCallback) + s.requireReason(err, biz.LoginHandoffReasonUnknown) +} diff --git a/app/controlplane/pkg/biz/testhelpers/database.go b/app/controlplane/pkg/biz/testhelpers/database.go index 83b956b52..acab54066 100644 --- a/app/controlplane/pkg/biz/testhelpers/database.go +++ b/app/controlplane/pkg/biz/testhelpers/database.go @@ -77,6 +77,7 @@ type TestingUseCases struct { Project *biz.ProjectUseCase OrgMetrics *biz.OrgMetricsUseCase Group *biz.GroupUseCase + LoginHandoff *biz.LoginHandoffUseCase // Repositories that can be used for custom crafting of use-cases Repos *TestingRepos } @@ -91,6 +92,7 @@ type TestingRepos struct { GroupRepo biz.GroupRepo OrgInvitationRepo biz.OrgInvitationRepo APITokenRepo biz.APITokenRepo + LoginHandoffRepo biz.LoginHandoffRepo } type newTestingOpts struct { diff --git a/app/controlplane/pkg/biz/testhelpers/wire_gen.go b/app/controlplane/pkg/biz/testhelpers/wire_gen.go index 447537e40..75bbabf80 100644 --- a/app/controlplane/pkg/biz/testhelpers/wire_gen.go +++ b/app/controlplane/pkg/biz/testhelpers/wire_gen.go @@ -170,6 +170,8 @@ func WireTestData(contextContext context.Context, testDatabase *TestDatabase, t projectVersionUseCase := biz.NewProjectVersionUseCase(projectVersionRepo, auditorUseCase, logger) groupUseCase := biz.NewGroupUseCase(logger, groupRepo, membershipRepo, userRepo, orgInvitationUseCase, auditorUseCase, orgInvitationRepo, authzUseCase, membershipUseCase) projectUseCase := biz.NewProjectsUseCase(logger, projectsRepo, membershipRepo, auditorUseCase, groupUseCase, membershipUseCase, orgInvitationUseCase, orgInvitationRepo, authzUseCase) + loginHandoffRepo := data.NewLoginHandoffRepo(dataData, logger) + loginHandoffUseCase := biz.NewLoginHandoffUseCase(loginHandoffRepo, logger) testingRepos := &TestingRepos{ Membership: membershipRepo, Referrer: referrerRepo, @@ -180,6 +182,7 @@ func WireTestData(contextContext context.Context, testDatabase *TestDatabase, t GroupRepo: groupRepo, OrgInvitationRepo: orgInvitationRepo, APITokenRepo: apiTokenRepo, + LoginHandoffRepo: loginHandoffRepo, } testingUseCases := &TestingUseCases{ DB: testDatabase, @@ -206,6 +209,7 @@ func WireTestData(contextContext context.Context, testDatabase *TestDatabase, t Project: projectUseCase, OrgMetrics: orgMetricsUseCase, Group: groupUseCase, + LoginHandoff: loginHandoffUseCase, Repos: testingRepos, } return testingUseCases, func() { diff --git a/app/controlplane/pkg/data/data.go b/app/controlplane/pkg/data/data.go index 04fb558c3..c5240548f 100644 --- a/app/controlplane/pkg/data/data.go +++ b/app/controlplane/pkg/data/data.go @@ -60,6 +60,7 @@ var ProviderSet = wire.NewSet( NewProjectVersionRepo, NewProjectsRepo, NewGroupRepo, + NewLoginHandoffRepo, NewPostgresLock, ) diff --git a/app/controlplane/pkg/data/ent/client.go b/app/controlplane/pkg/data/ent/client.go index 5e8db4503..a7d16ee51 100644 --- a/app/controlplane/pkg/data/ent/client.go +++ b/app/controlplane/pkg/data/ent/client.go @@ -24,6 +24,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/groupmembership" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/integration" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/integrationattachment" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/loginhandoff" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/membership" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/organization" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/orginvitation" @@ -59,6 +60,8 @@ type Client struct { Integration *IntegrationClient // IntegrationAttachment is the client for interacting with the IntegrationAttachment builders. IntegrationAttachment *IntegrationAttachmentClient + // LoginHandoff is the client for interacting with the LoginHandoff builders. + LoginHandoff *LoginHandoffClient // Membership is the client for interacting with the Membership builders. Membership *MembershipClient // OrgInvitation is the client for interacting with the OrgInvitation builders. @@ -102,6 +105,7 @@ func (c *Client) init() { c.GroupMembership = NewGroupMembershipClient(c.config) c.Integration = NewIntegrationClient(c.config) c.IntegrationAttachment = NewIntegrationAttachmentClient(c.config) + c.LoginHandoff = NewLoginHandoffClient(c.config) c.Membership = NewMembershipClient(c.config) c.OrgInvitation = NewOrgInvitationClient(c.config) c.Organization = NewOrganizationClient(c.config) @@ -214,6 +218,7 @@ func (c *Client) Tx(ctx context.Context) (*Tx, error) { GroupMembership: NewGroupMembershipClient(cfg), Integration: NewIntegrationClient(cfg), IntegrationAttachment: NewIntegrationAttachmentClient(cfg), + LoginHandoff: NewLoginHandoffClient(cfg), Membership: NewMembershipClient(cfg), OrgInvitation: NewOrgInvitationClient(cfg), Organization: NewOrganizationClient(cfg), @@ -253,6 +258,7 @@ func (c *Client) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) GroupMembership: NewGroupMembershipClient(cfg), Integration: NewIntegrationClient(cfg), IntegrationAttachment: NewIntegrationAttachmentClient(cfg), + LoginHandoff: NewLoginHandoffClient(cfg), Membership: NewMembershipClient(cfg), OrgInvitation: NewOrgInvitationClient(cfg), Organization: NewOrganizationClient(cfg), @@ -295,9 +301,9 @@ func (c *Client) Close() error { func (c *Client) Use(hooks ...Hook) { for _, n := range []interface{ Use(...Hook) }{ c.APIToken, c.Attestation, c.CASBackend, c.CASMapping, c.Group, - c.GroupMembership, c.Integration, c.IntegrationAttachment, c.Membership, - c.OrgInvitation, c.Organization, c.Project, c.ProjectVersion, c.Referrer, - c.RobotAccount, c.User, c.Workflow, c.WorkflowContract, + c.GroupMembership, c.Integration, c.IntegrationAttachment, c.LoginHandoff, + c.Membership, c.OrgInvitation, c.Organization, c.Project, c.ProjectVersion, + c.Referrer, c.RobotAccount, c.User, c.Workflow, c.WorkflowContract, c.WorkflowContractVersion, c.WorkflowRun, } { n.Use(hooks...) @@ -309,9 +315,9 @@ func (c *Client) Use(hooks ...Hook) { func (c *Client) Intercept(interceptors ...Interceptor) { for _, n := range []interface{ Intercept(...Interceptor) }{ c.APIToken, c.Attestation, c.CASBackend, c.CASMapping, c.Group, - c.GroupMembership, c.Integration, c.IntegrationAttachment, c.Membership, - c.OrgInvitation, c.Organization, c.Project, c.ProjectVersion, c.Referrer, - c.RobotAccount, c.User, c.Workflow, c.WorkflowContract, + c.GroupMembership, c.Integration, c.IntegrationAttachment, c.LoginHandoff, + c.Membership, c.OrgInvitation, c.Organization, c.Project, c.ProjectVersion, + c.Referrer, c.RobotAccount, c.User, c.Workflow, c.WorkflowContract, c.WorkflowContractVersion, c.WorkflowRun, } { n.Intercept(interceptors...) @@ -337,6 +343,8 @@ func (c *Client) Mutate(ctx context.Context, m Mutation) (Value, error) { return c.Integration.mutate(ctx, m) case *IntegrationAttachmentMutation: return c.IntegrationAttachment.mutate(ctx, m) + case *LoginHandoffMutation: + return c.LoginHandoff.mutate(ctx, m) case *MembershipMutation: return c.Membership.mutate(ctx, m) case *OrgInvitationMutation: @@ -1702,6 +1710,155 @@ func (c *IntegrationAttachmentClient) mutate(ctx context.Context, m *Integration } } +// LoginHandoffClient is a client for the LoginHandoff schema. +type LoginHandoffClient struct { + config +} + +// NewLoginHandoffClient returns a client for the LoginHandoff from the given config. +func NewLoginHandoffClient(c config) *LoginHandoffClient { + return &LoginHandoffClient{config: c} +} + +// Use adds a list of mutation hooks to the hooks stack. +// A call to `Use(f, g, h)` equals to `loginhandoff.Hooks(f(g(h())))`. +func (c *LoginHandoffClient) Use(hooks ...Hook) { + c.hooks.LoginHandoff = append(c.hooks.LoginHandoff, hooks...) +} + +// Intercept adds a list of query interceptors to the interceptors stack. +// A call to `Intercept(f, g, h)` equals to `loginhandoff.Intercept(f(g(h())))`. +func (c *LoginHandoffClient) Intercept(interceptors ...Interceptor) { + c.inters.LoginHandoff = append(c.inters.LoginHandoff, interceptors...) +} + +// Create returns a builder for creating a LoginHandoff entity. +func (c *LoginHandoffClient) Create() *LoginHandoffCreate { + mutation := newLoginHandoffMutation(c.config, OpCreate) + return &LoginHandoffCreate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// CreateBulk returns a builder for creating a bulk of LoginHandoff entities. +func (c *LoginHandoffClient) CreateBulk(builders ...*LoginHandoffCreate) *LoginHandoffCreateBulk { + return &LoginHandoffCreateBulk{config: c.config, builders: builders} +} + +// MapCreateBulk creates a bulk creation builder from the given slice. For each item in the slice, the function creates +// a builder and applies setFunc on it. +func (c *LoginHandoffClient) MapCreateBulk(slice any, setFunc func(*LoginHandoffCreate, int)) *LoginHandoffCreateBulk { + rv := reflect.ValueOf(slice) + if rv.Kind() != reflect.Slice { + return &LoginHandoffCreateBulk{err: fmt.Errorf("calling to LoginHandoffClient.MapCreateBulk with wrong type %T, need slice", slice)} + } + builders := make([]*LoginHandoffCreate, rv.Len()) + for i := 0; i < rv.Len(); i++ { + builders[i] = c.Create() + setFunc(builders[i], i) + } + return &LoginHandoffCreateBulk{config: c.config, builders: builders} +} + +// Update returns an update builder for LoginHandoff. +func (c *LoginHandoffClient) Update() *LoginHandoffUpdate { + mutation := newLoginHandoffMutation(c.config, OpUpdate) + return &LoginHandoffUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOne returns an update builder for the given entity. +func (c *LoginHandoffClient) UpdateOne(_m *LoginHandoff) *LoginHandoffUpdateOne { + mutation := newLoginHandoffMutation(c.config, OpUpdateOne, withLoginHandoff(_m)) + return &LoginHandoffUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOneID returns an update builder for the given id. +func (c *LoginHandoffClient) UpdateOneID(id uuid.UUID) *LoginHandoffUpdateOne { + mutation := newLoginHandoffMutation(c.config, OpUpdateOne, withLoginHandoffID(id)) + return &LoginHandoffUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// Delete returns a delete builder for LoginHandoff. +func (c *LoginHandoffClient) Delete() *LoginHandoffDelete { + mutation := newLoginHandoffMutation(c.config, OpDelete) + return &LoginHandoffDelete{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// DeleteOne returns a builder for deleting the given entity. +func (c *LoginHandoffClient) DeleteOne(_m *LoginHandoff) *LoginHandoffDeleteOne { + return c.DeleteOneID(_m.ID) +} + +// DeleteOneID returns a builder for deleting the given entity by its id. +func (c *LoginHandoffClient) DeleteOneID(id uuid.UUID) *LoginHandoffDeleteOne { + builder := c.Delete().Where(loginhandoff.ID(id)) + builder.mutation.id = &id + builder.mutation.op = OpDeleteOne + return &LoginHandoffDeleteOne{builder} +} + +// Query returns a query builder for LoginHandoff. +func (c *LoginHandoffClient) Query() *LoginHandoffQuery { + return &LoginHandoffQuery{ + config: c.config, + ctx: &QueryContext{Type: TypeLoginHandoff}, + inters: c.Interceptors(), + } +} + +// Get returns a LoginHandoff entity by its id. +func (c *LoginHandoffClient) Get(ctx context.Context, id uuid.UUID) (*LoginHandoff, error) { + return c.Query().Where(loginhandoff.ID(id)).Only(ctx) +} + +// GetX is like Get, but panics if an error occurs. +func (c *LoginHandoffClient) GetX(ctx context.Context, id uuid.UUID) *LoginHandoff { + obj, err := c.Get(ctx, id) + if err != nil { + panic(err) + } + return obj +} + +// QueryUser queries the user edge of a LoginHandoff. +func (c *LoginHandoffClient) QueryUser(_m *LoginHandoff) *UserQuery { + query := (&UserClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(loginhandoff.Table, loginhandoff.FieldID, id), + sqlgraph.To(user.Table, user.FieldID), + sqlgraph.Edge(sqlgraph.M2O, false, loginhandoff.UserTable, loginhandoff.UserColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + +// Hooks returns the client hooks. +func (c *LoginHandoffClient) Hooks() []Hook { + return c.hooks.LoginHandoff +} + +// Interceptors returns the client interceptors. +func (c *LoginHandoffClient) Interceptors() []Interceptor { + return c.inters.LoginHandoff +} + +func (c *LoginHandoffClient) mutate(ctx context.Context, m *LoginHandoffMutation) (Value, error) { + switch m.Op() { + case OpCreate: + return (&LoginHandoffCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdate: + return (&LoginHandoffUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdateOne: + return (&LoginHandoffUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpDelete, OpDeleteOne: + return (&LoginHandoffDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx) + default: + return nil, fmt.Errorf("ent: unknown LoginHandoff mutation op: %q", m.Op()) + } +} + // MembershipClient is a client for the Membership schema. type MembershipClient struct { config @@ -4006,14 +4163,14 @@ func (c *WorkflowRunClient) mutate(ctx context.Context, m *WorkflowRunMutation) type ( hooks struct { APIToken, Attestation, CASBackend, CASMapping, Group, GroupMembership, - Integration, IntegrationAttachment, Membership, OrgInvitation, Organization, - Project, ProjectVersion, Referrer, RobotAccount, User, Workflow, + Integration, IntegrationAttachment, LoginHandoff, Membership, OrgInvitation, + Organization, Project, ProjectVersion, Referrer, RobotAccount, User, Workflow, WorkflowContract, WorkflowContractVersion, WorkflowRun []ent.Hook } inters struct { APIToken, Attestation, CASBackend, CASMapping, Group, GroupMembership, - Integration, IntegrationAttachment, Membership, OrgInvitation, Organization, - Project, ProjectVersion, Referrer, RobotAccount, User, Workflow, + Integration, IntegrationAttachment, LoginHandoff, Membership, OrgInvitation, + Organization, Project, ProjectVersion, Referrer, RobotAccount, User, Workflow, WorkflowContract, WorkflowContractVersion, WorkflowRun []ent.Interceptor } ) diff --git a/app/controlplane/pkg/data/ent/ent.go b/app/controlplane/pkg/data/ent/ent.go index fc1fe487a..3422f6194 100644 --- a/app/controlplane/pkg/data/ent/ent.go +++ b/app/controlplane/pkg/data/ent/ent.go @@ -20,6 +20,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/groupmembership" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/integration" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/integrationattachment" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/loginhandoff" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/membership" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/organization" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/orginvitation" @@ -100,6 +101,7 @@ func checkColumn(t, c string) error { groupmembership.Table: groupmembership.ValidColumn, integration.Table: integration.ValidColumn, integrationattachment.Table: integrationattachment.ValidColumn, + loginhandoff.Table: loginhandoff.ValidColumn, membership.Table: membership.ValidColumn, orginvitation.Table: orginvitation.ValidColumn, organization.Table: organization.ValidColumn, diff --git a/app/controlplane/pkg/data/ent/hook/hook.go b/app/controlplane/pkg/data/ent/hook/hook.go index 0015ac265..49d424b61 100644 --- a/app/controlplane/pkg/data/ent/hook/hook.go +++ b/app/controlplane/pkg/data/ent/hook/hook.go @@ -105,6 +105,18 @@ func (f IntegrationAttachmentFunc) Mutate(ctx context.Context, m ent.Mutation) ( return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.IntegrationAttachmentMutation", m) } +// The LoginHandoffFunc type is an adapter to allow the use of ordinary +// function as LoginHandoff mutator. +type LoginHandoffFunc func(context.Context, *ent.LoginHandoffMutation) (ent.Value, error) + +// Mutate calls f(ctx, m). +func (f LoginHandoffFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { + if mv, ok := m.(*ent.LoginHandoffMutation); ok { + return f(ctx, mv) + } + return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.LoginHandoffMutation", m) +} + // The MembershipFunc type is an adapter to allow the use of ordinary // function as Membership mutator. type MembershipFunc func(context.Context, *ent.MembershipMutation) (ent.Value, error) diff --git a/app/controlplane/pkg/data/ent/loginhandoff.go b/app/controlplane/pkg/data/ent/loginhandoff.go new file mode 100644 index 000000000..4d67f1714 --- /dev/null +++ b/app/controlplane/pkg/data/ent/loginhandoff.go @@ -0,0 +1,207 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "fmt" + "strings" + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect/sql" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/loginhandoff" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/user" + "github.com/google/uuid" +) + +// LoginHandoff is the model entity for the LoginHandoff schema. +type LoginHandoff struct { + config `json:"-"` + // ID of the ent. + ID uuid.UUID `json:"id,omitempty"` + // CodeHash holds the value of the "code_hash" field. + CodeHash string `json:"code_hash,omitempty"` + // UserID holds the value of the "user_id" field. + UserID uuid.UUID `json:"user_id,omitempty"` + // TokenTTLSeconds holds the value of the "token_ttl_seconds" field. + TokenTTLSeconds int64 `json:"token_ttl_seconds,omitempty"` + // CodeChallenge holds the value of the "code_challenge" field. + CodeChallenge string `json:"code_challenge,omitempty"` + // Callback holds the value of the "callback" field. + Callback string `json:"callback,omitempty"` + // ExpiresAt holds the value of the "expires_at" field. + ExpiresAt time.Time `json:"expires_at,omitempty"` + // UsedAt holds the value of the "used_at" field. + UsedAt *time.Time `json:"used_at,omitempty"` + // Edges holds the relations/edges for other nodes in the graph. + // The values are being populated by the LoginHandoffQuery when eager-loading is set. + Edges LoginHandoffEdges `json:"edges"` + selectValues sql.SelectValues +} + +// LoginHandoffEdges holds the relations/edges for other nodes in the graph. +type LoginHandoffEdges struct { + // User holds the value of the user edge. + User *User `json:"user,omitempty"` + // loadedTypes holds the information for reporting if a + // type was loaded (or requested) in eager-loading or not. + loadedTypes [1]bool +} + +// UserOrErr returns the User value or an error if the edge +// was not loaded in eager-loading, or loaded but was not found. +func (e LoginHandoffEdges) UserOrErr() (*User, error) { + if e.User != nil { + return e.User, nil + } else if e.loadedTypes[0] { + return nil, &NotFoundError{label: user.Label} + } + return nil, &NotLoadedError{edge: "user"} +} + +// scanValues returns the types for scanning values from sql.Rows. +func (*LoginHandoff) scanValues(columns []string) ([]any, error) { + values := make([]any, len(columns)) + for i := range columns { + switch columns[i] { + case loginhandoff.FieldTokenTTLSeconds: + values[i] = new(sql.NullInt64) + case loginhandoff.FieldCodeHash, loginhandoff.FieldCodeChallenge, loginhandoff.FieldCallback: + values[i] = new(sql.NullString) + case loginhandoff.FieldExpiresAt, loginhandoff.FieldUsedAt: + values[i] = new(sql.NullTime) + case loginhandoff.FieldID, loginhandoff.FieldUserID: + values[i] = new(uuid.UUID) + default: + values[i] = new(sql.UnknownType) + } + } + return values, nil +} + +// assignValues assigns the values that were returned from sql.Rows (after scanning) +// to the LoginHandoff fields. +func (_m *LoginHandoff) assignValues(columns []string, values []any) error { + if m, n := len(values), len(columns); m < n { + return fmt.Errorf("mismatch number of scan values: %d != %d", m, n) + } + for i := range columns { + switch columns[i] { + case loginhandoff.FieldID: + if value, ok := values[i].(*uuid.UUID); !ok { + return fmt.Errorf("unexpected type %T for field id", values[i]) + } else if value != nil { + _m.ID = *value + } + case loginhandoff.FieldCodeHash: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field code_hash", values[i]) + } else if value.Valid { + _m.CodeHash = value.String + } + case loginhandoff.FieldUserID: + if value, ok := values[i].(*uuid.UUID); !ok { + return fmt.Errorf("unexpected type %T for field user_id", values[i]) + } else if value != nil { + _m.UserID = *value + } + case loginhandoff.FieldTokenTTLSeconds: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field token_ttl_seconds", values[i]) + } else if value.Valid { + _m.TokenTTLSeconds = value.Int64 + } + case loginhandoff.FieldCodeChallenge: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field code_challenge", values[i]) + } else if value.Valid { + _m.CodeChallenge = value.String + } + case loginhandoff.FieldCallback: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field callback", values[i]) + } else if value.Valid { + _m.Callback = value.String + } + case loginhandoff.FieldExpiresAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field expires_at", values[i]) + } else if value.Valid { + _m.ExpiresAt = value.Time + } + case loginhandoff.FieldUsedAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field used_at", values[i]) + } else if value.Valid { + _m.UsedAt = new(time.Time) + *_m.UsedAt = value.Time + } + default: + _m.selectValues.Set(columns[i], values[i]) + } + } + return nil +} + +// Value returns the ent.Value that was dynamically selected and assigned to the LoginHandoff. +// This includes values selected through modifiers, order, etc. +func (_m *LoginHandoff) Value(name string) (ent.Value, error) { + return _m.selectValues.Get(name) +} + +// QueryUser queries the "user" edge of the LoginHandoff entity. +func (_m *LoginHandoff) QueryUser() *UserQuery { + return NewLoginHandoffClient(_m.config).QueryUser(_m) +} + +// Update returns a builder for updating this LoginHandoff. +// Note that you need to call LoginHandoff.Unwrap() before calling this method if this LoginHandoff +// was returned from a transaction, and the transaction was committed or rolled back. +func (_m *LoginHandoff) Update() *LoginHandoffUpdateOne { + return NewLoginHandoffClient(_m.config).UpdateOne(_m) +} + +// Unwrap unwraps the LoginHandoff entity that was returned from a transaction after it was closed, +// so that all future queries will be executed through the driver which created the transaction. +func (_m *LoginHandoff) Unwrap() *LoginHandoff { + _tx, ok := _m.config.driver.(*txDriver) + if !ok { + panic("ent: LoginHandoff is not a transactional entity") + } + _m.config.driver = _tx.drv + return _m +} + +// String implements the fmt.Stringer. +func (_m *LoginHandoff) String() string { + var builder strings.Builder + builder.WriteString("LoginHandoff(") + builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID)) + builder.WriteString("code_hash=") + builder.WriteString(_m.CodeHash) + builder.WriteString(", ") + builder.WriteString("user_id=") + builder.WriteString(fmt.Sprintf("%v", _m.UserID)) + builder.WriteString(", ") + builder.WriteString("token_ttl_seconds=") + builder.WriteString(fmt.Sprintf("%v", _m.TokenTTLSeconds)) + builder.WriteString(", ") + builder.WriteString("code_challenge=") + builder.WriteString(_m.CodeChallenge) + builder.WriteString(", ") + builder.WriteString("callback=") + builder.WriteString(_m.Callback) + builder.WriteString(", ") + builder.WriteString("expires_at=") + builder.WriteString(_m.ExpiresAt.Format(time.ANSIC)) + builder.WriteString(", ") + if v := _m.UsedAt; v != nil { + builder.WriteString("used_at=") + builder.WriteString(v.Format(time.ANSIC)) + } + builder.WriteByte(')') + return builder.String() +} + +// LoginHandoffs is a parsable slice of LoginHandoff. +type LoginHandoffs []*LoginHandoff diff --git a/app/controlplane/pkg/data/ent/loginhandoff/loginhandoff.go b/app/controlplane/pkg/data/ent/loginhandoff/loginhandoff.go new file mode 100644 index 000000000..382b2c477 --- /dev/null +++ b/app/controlplane/pkg/data/ent/loginhandoff/loginhandoff.go @@ -0,0 +1,125 @@ +// Code generated by ent, DO NOT EDIT. + +package loginhandoff + +import ( + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "github.com/google/uuid" +) + +const ( + // Label holds the string label denoting the loginhandoff type in the database. + Label = "login_handoff" + // FieldID holds the string denoting the id field in the database. + FieldID = "id" + // FieldCodeHash holds the string denoting the code_hash field in the database. + FieldCodeHash = "code_hash" + // FieldUserID holds the string denoting the user_id field in the database. + FieldUserID = "user_id" + // FieldTokenTTLSeconds holds the string denoting the token_ttl_seconds field in the database. + FieldTokenTTLSeconds = "token_ttl_seconds" + // FieldCodeChallenge holds the string denoting the code_challenge field in the database. + FieldCodeChallenge = "code_challenge" + // FieldCallback holds the string denoting the callback field in the database. + FieldCallback = "callback" + // FieldExpiresAt holds the string denoting the expires_at field in the database. + FieldExpiresAt = "expires_at" + // FieldUsedAt holds the string denoting the used_at field in the database. + FieldUsedAt = "used_at" + // EdgeUser holds the string denoting the user edge name in mutations. + EdgeUser = "user" + // Table holds the table name of the loginhandoff in the database. + Table = "login_handoffs" + // UserTable is the table that holds the user relation/edge. + UserTable = "login_handoffs" + // UserInverseTable is the table name for the User entity. + // It exists in this package in order to avoid circular dependency with the "user" package. + UserInverseTable = "users" + // UserColumn is the table column denoting the user relation/edge. + UserColumn = "user_id" +) + +// Columns holds all SQL columns for loginhandoff fields. +var Columns = []string{ + FieldID, + FieldCodeHash, + FieldUserID, + FieldTokenTTLSeconds, + FieldCodeChallenge, + FieldCallback, + FieldExpiresAt, + FieldUsedAt, +} + +// ValidColumn reports if the column name is valid (part of the table columns). +func ValidColumn(column string) bool { + for i := range Columns { + if column == Columns[i] { + return true + } + } + return false +} + +var ( + // DefaultID holds the default value on creation for the "id" field. + DefaultID func() uuid.UUID +) + +// OrderOption defines the ordering options for the LoginHandoff queries. +type OrderOption func(*sql.Selector) + +// ByID orders the results by the id field. +func ByID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldID, opts...).ToFunc() +} + +// ByCodeHash orders the results by the code_hash field. +func ByCodeHash(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldCodeHash, opts...).ToFunc() +} + +// ByUserID orders the results by the user_id field. +func ByUserID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUserID, opts...).ToFunc() +} + +// ByTokenTTLSeconds orders the results by the token_ttl_seconds field. +func ByTokenTTLSeconds(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldTokenTTLSeconds, opts...).ToFunc() +} + +// ByCodeChallenge orders the results by the code_challenge field. +func ByCodeChallenge(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldCodeChallenge, opts...).ToFunc() +} + +// ByCallback orders the results by the callback field. +func ByCallback(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldCallback, opts...).ToFunc() +} + +// ByExpiresAt orders the results by the expires_at field. +func ByExpiresAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldExpiresAt, opts...).ToFunc() +} + +// ByUsedAt orders the results by the used_at field. +func ByUsedAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUsedAt, opts...).ToFunc() +} + +// ByUserField orders the results by user field. +func ByUserField(field string, opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newUserStep(), sql.OrderByField(field, opts...)) + } +} +func newUserStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(UserInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.M2O, false, UserTable, UserColumn), + ) +} diff --git a/app/controlplane/pkg/data/ent/loginhandoff/where.go b/app/controlplane/pkg/data/ent/loginhandoff/where.go new file mode 100644 index 000000000..ac2b2bb0e --- /dev/null +++ b/app/controlplane/pkg/data/ent/loginhandoff/where.go @@ -0,0 +1,475 @@ +// Code generated by ent, DO NOT EDIT. + +package loginhandoff + +import ( + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/predicate" + "github.com/google/uuid" +) + +// ID filters vertices based on their ID field. +func ID(id uuid.UUID) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEQ(FieldID, id)) +} + +// IDEQ applies the EQ predicate on the ID field. +func IDEQ(id uuid.UUID) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEQ(FieldID, id)) +} + +// IDNEQ applies the NEQ predicate on the ID field. +func IDNEQ(id uuid.UUID) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldNEQ(FieldID, id)) +} + +// IDIn applies the In predicate on the ID field. +func IDIn(ids ...uuid.UUID) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldIn(FieldID, ids...)) +} + +// IDNotIn applies the NotIn predicate on the ID field. +func IDNotIn(ids ...uuid.UUID) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldNotIn(FieldID, ids...)) +} + +// IDGT applies the GT predicate on the ID field. +func IDGT(id uuid.UUID) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldGT(FieldID, id)) +} + +// IDGTE applies the GTE predicate on the ID field. +func IDGTE(id uuid.UUID) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldGTE(FieldID, id)) +} + +// IDLT applies the LT predicate on the ID field. +func IDLT(id uuid.UUID) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldLT(FieldID, id)) +} + +// IDLTE applies the LTE predicate on the ID field. +func IDLTE(id uuid.UUID) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldLTE(FieldID, id)) +} + +// CodeHash applies equality check predicate on the "code_hash" field. It's identical to CodeHashEQ. +func CodeHash(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEQ(FieldCodeHash, v)) +} + +// UserID applies equality check predicate on the "user_id" field. It's identical to UserIDEQ. +func UserID(v uuid.UUID) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEQ(FieldUserID, v)) +} + +// TokenTTLSeconds applies equality check predicate on the "token_ttl_seconds" field. It's identical to TokenTTLSecondsEQ. +func TokenTTLSeconds(v int64) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEQ(FieldTokenTTLSeconds, v)) +} + +// CodeChallenge applies equality check predicate on the "code_challenge" field. It's identical to CodeChallengeEQ. +func CodeChallenge(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEQ(FieldCodeChallenge, v)) +} + +// Callback applies equality check predicate on the "callback" field. It's identical to CallbackEQ. +func Callback(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEQ(FieldCallback, v)) +} + +// ExpiresAt applies equality check predicate on the "expires_at" field. It's identical to ExpiresAtEQ. +func ExpiresAt(v time.Time) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEQ(FieldExpiresAt, v)) +} + +// UsedAt applies equality check predicate on the "used_at" field. It's identical to UsedAtEQ. +func UsedAt(v time.Time) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEQ(FieldUsedAt, v)) +} + +// CodeHashEQ applies the EQ predicate on the "code_hash" field. +func CodeHashEQ(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEQ(FieldCodeHash, v)) +} + +// CodeHashNEQ applies the NEQ predicate on the "code_hash" field. +func CodeHashNEQ(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldNEQ(FieldCodeHash, v)) +} + +// CodeHashIn applies the In predicate on the "code_hash" field. +func CodeHashIn(vs ...string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldIn(FieldCodeHash, vs...)) +} + +// CodeHashNotIn applies the NotIn predicate on the "code_hash" field. +func CodeHashNotIn(vs ...string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldNotIn(FieldCodeHash, vs...)) +} + +// CodeHashGT applies the GT predicate on the "code_hash" field. +func CodeHashGT(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldGT(FieldCodeHash, v)) +} + +// CodeHashGTE applies the GTE predicate on the "code_hash" field. +func CodeHashGTE(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldGTE(FieldCodeHash, v)) +} + +// CodeHashLT applies the LT predicate on the "code_hash" field. +func CodeHashLT(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldLT(FieldCodeHash, v)) +} + +// CodeHashLTE applies the LTE predicate on the "code_hash" field. +func CodeHashLTE(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldLTE(FieldCodeHash, v)) +} + +// CodeHashContains applies the Contains predicate on the "code_hash" field. +func CodeHashContains(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldContains(FieldCodeHash, v)) +} + +// CodeHashHasPrefix applies the HasPrefix predicate on the "code_hash" field. +func CodeHashHasPrefix(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldHasPrefix(FieldCodeHash, v)) +} + +// CodeHashHasSuffix applies the HasSuffix predicate on the "code_hash" field. +func CodeHashHasSuffix(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldHasSuffix(FieldCodeHash, v)) +} + +// CodeHashEqualFold applies the EqualFold predicate on the "code_hash" field. +func CodeHashEqualFold(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEqualFold(FieldCodeHash, v)) +} + +// CodeHashContainsFold applies the ContainsFold predicate on the "code_hash" field. +func CodeHashContainsFold(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldContainsFold(FieldCodeHash, v)) +} + +// UserIDEQ applies the EQ predicate on the "user_id" field. +func UserIDEQ(v uuid.UUID) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEQ(FieldUserID, v)) +} + +// UserIDNEQ applies the NEQ predicate on the "user_id" field. +func UserIDNEQ(v uuid.UUID) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldNEQ(FieldUserID, v)) +} + +// UserIDIn applies the In predicate on the "user_id" field. +func UserIDIn(vs ...uuid.UUID) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldIn(FieldUserID, vs...)) +} + +// UserIDNotIn applies the NotIn predicate on the "user_id" field. +func UserIDNotIn(vs ...uuid.UUID) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldNotIn(FieldUserID, vs...)) +} + +// TokenTTLSecondsEQ applies the EQ predicate on the "token_ttl_seconds" field. +func TokenTTLSecondsEQ(v int64) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEQ(FieldTokenTTLSeconds, v)) +} + +// TokenTTLSecondsNEQ applies the NEQ predicate on the "token_ttl_seconds" field. +func TokenTTLSecondsNEQ(v int64) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldNEQ(FieldTokenTTLSeconds, v)) +} + +// TokenTTLSecondsIn applies the In predicate on the "token_ttl_seconds" field. +func TokenTTLSecondsIn(vs ...int64) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldIn(FieldTokenTTLSeconds, vs...)) +} + +// TokenTTLSecondsNotIn applies the NotIn predicate on the "token_ttl_seconds" field. +func TokenTTLSecondsNotIn(vs ...int64) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldNotIn(FieldTokenTTLSeconds, vs...)) +} + +// TokenTTLSecondsGT applies the GT predicate on the "token_ttl_seconds" field. +func TokenTTLSecondsGT(v int64) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldGT(FieldTokenTTLSeconds, v)) +} + +// TokenTTLSecondsGTE applies the GTE predicate on the "token_ttl_seconds" field. +func TokenTTLSecondsGTE(v int64) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldGTE(FieldTokenTTLSeconds, v)) +} + +// TokenTTLSecondsLT applies the LT predicate on the "token_ttl_seconds" field. +func TokenTTLSecondsLT(v int64) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldLT(FieldTokenTTLSeconds, v)) +} + +// TokenTTLSecondsLTE applies the LTE predicate on the "token_ttl_seconds" field. +func TokenTTLSecondsLTE(v int64) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldLTE(FieldTokenTTLSeconds, v)) +} + +// CodeChallengeEQ applies the EQ predicate on the "code_challenge" field. +func CodeChallengeEQ(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEQ(FieldCodeChallenge, v)) +} + +// CodeChallengeNEQ applies the NEQ predicate on the "code_challenge" field. +func CodeChallengeNEQ(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldNEQ(FieldCodeChallenge, v)) +} + +// CodeChallengeIn applies the In predicate on the "code_challenge" field. +func CodeChallengeIn(vs ...string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldIn(FieldCodeChallenge, vs...)) +} + +// CodeChallengeNotIn applies the NotIn predicate on the "code_challenge" field. +func CodeChallengeNotIn(vs ...string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldNotIn(FieldCodeChallenge, vs...)) +} + +// CodeChallengeGT applies the GT predicate on the "code_challenge" field. +func CodeChallengeGT(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldGT(FieldCodeChallenge, v)) +} + +// CodeChallengeGTE applies the GTE predicate on the "code_challenge" field. +func CodeChallengeGTE(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldGTE(FieldCodeChallenge, v)) +} + +// CodeChallengeLT applies the LT predicate on the "code_challenge" field. +func CodeChallengeLT(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldLT(FieldCodeChallenge, v)) +} + +// CodeChallengeLTE applies the LTE predicate on the "code_challenge" field. +func CodeChallengeLTE(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldLTE(FieldCodeChallenge, v)) +} + +// CodeChallengeContains applies the Contains predicate on the "code_challenge" field. +func CodeChallengeContains(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldContains(FieldCodeChallenge, v)) +} + +// CodeChallengeHasPrefix applies the HasPrefix predicate on the "code_challenge" field. +func CodeChallengeHasPrefix(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldHasPrefix(FieldCodeChallenge, v)) +} + +// CodeChallengeHasSuffix applies the HasSuffix predicate on the "code_challenge" field. +func CodeChallengeHasSuffix(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldHasSuffix(FieldCodeChallenge, v)) +} + +// CodeChallengeEqualFold applies the EqualFold predicate on the "code_challenge" field. +func CodeChallengeEqualFold(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEqualFold(FieldCodeChallenge, v)) +} + +// CodeChallengeContainsFold applies the ContainsFold predicate on the "code_challenge" field. +func CodeChallengeContainsFold(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldContainsFold(FieldCodeChallenge, v)) +} + +// CallbackEQ applies the EQ predicate on the "callback" field. +func CallbackEQ(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEQ(FieldCallback, v)) +} + +// CallbackNEQ applies the NEQ predicate on the "callback" field. +func CallbackNEQ(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldNEQ(FieldCallback, v)) +} + +// CallbackIn applies the In predicate on the "callback" field. +func CallbackIn(vs ...string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldIn(FieldCallback, vs...)) +} + +// CallbackNotIn applies the NotIn predicate on the "callback" field. +func CallbackNotIn(vs ...string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldNotIn(FieldCallback, vs...)) +} + +// CallbackGT applies the GT predicate on the "callback" field. +func CallbackGT(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldGT(FieldCallback, v)) +} + +// CallbackGTE applies the GTE predicate on the "callback" field. +func CallbackGTE(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldGTE(FieldCallback, v)) +} + +// CallbackLT applies the LT predicate on the "callback" field. +func CallbackLT(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldLT(FieldCallback, v)) +} + +// CallbackLTE applies the LTE predicate on the "callback" field. +func CallbackLTE(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldLTE(FieldCallback, v)) +} + +// CallbackContains applies the Contains predicate on the "callback" field. +func CallbackContains(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldContains(FieldCallback, v)) +} + +// CallbackHasPrefix applies the HasPrefix predicate on the "callback" field. +func CallbackHasPrefix(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldHasPrefix(FieldCallback, v)) +} + +// CallbackHasSuffix applies the HasSuffix predicate on the "callback" field. +func CallbackHasSuffix(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldHasSuffix(FieldCallback, v)) +} + +// CallbackEqualFold applies the EqualFold predicate on the "callback" field. +func CallbackEqualFold(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEqualFold(FieldCallback, v)) +} + +// CallbackContainsFold applies the ContainsFold predicate on the "callback" field. +func CallbackContainsFold(v string) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldContainsFold(FieldCallback, v)) +} + +// ExpiresAtEQ applies the EQ predicate on the "expires_at" field. +func ExpiresAtEQ(v time.Time) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEQ(FieldExpiresAt, v)) +} + +// ExpiresAtNEQ applies the NEQ predicate on the "expires_at" field. +func ExpiresAtNEQ(v time.Time) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldNEQ(FieldExpiresAt, v)) +} + +// ExpiresAtIn applies the In predicate on the "expires_at" field. +func ExpiresAtIn(vs ...time.Time) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldIn(FieldExpiresAt, vs...)) +} + +// ExpiresAtNotIn applies the NotIn predicate on the "expires_at" field. +func ExpiresAtNotIn(vs ...time.Time) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldNotIn(FieldExpiresAt, vs...)) +} + +// ExpiresAtGT applies the GT predicate on the "expires_at" field. +func ExpiresAtGT(v time.Time) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldGT(FieldExpiresAt, v)) +} + +// ExpiresAtGTE applies the GTE predicate on the "expires_at" field. +func ExpiresAtGTE(v time.Time) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldGTE(FieldExpiresAt, v)) +} + +// ExpiresAtLT applies the LT predicate on the "expires_at" field. +func ExpiresAtLT(v time.Time) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldLT(FieldExpiresAt, v)) +} + +// ExpiresAtLTE applies the LTE predicate on the "expires_at" field. +func ExpiresAtLTE(v time.Time) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldLTE(FieldExpiresAt, v)) +} + +// UsedAtEQ applies the EQ predicate on the "used_at" field. +func UsedAtEQ(v time.Time) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldEQ(FieldUsedAt, v)) +} + +// UsedAtNEQ applies the NEQ predicate on the "used_at" field. +func UsedAtNEQ(v time.Time) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldNEQ(FieldUsedAt, v)) +} + +// UsedAtIn applies the In predicate on the "used_at" field. +func UsedAtIn(vs ...time.Time) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldIn(FieldUsedAt, vs...)) +} + +// UsedAtNotIn applies the NotIn predicate on the "used_at" field. +func UsedAtNotIn(vs ...time.Time) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldNotIn(FieldUsedAt, vs...)) +} + +// UsedAtGT applies the GT predicate on the "used_at" field. +func UsedAtGT(v time.Time) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldGT(FieldUsedAt, v)) +} + +// UsedAtGTE applies the GTE predicate on the "used_at" field. +func UsedAtGTE(v time.Time) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldGTE(FieldUsedAt, v)) +} + +// UsedAtLT applies the LT predicate on the "used_at" field. +func UsedAtLT(v time.Time) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldLT(FieldUsedAt, v)) +} + +// UsedAtLTE applies the LTE predicate on the "used_at" field. +func UsedAtLTE(v time.Time) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldLTE(FieldUsedAt, v)) +} + +// UsedAtIsNil applies the IsNil predicate on the "used_at" field. +func UsedAtIsNil() predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldIsNull(FieldUsedAt)) +} + +// UsedAtNotNil applies the NotNil predicate on the "used_at" field. +func UsedAtNotNil() predicate.LoginHandoff { + return predicate.LoginHandoff(sql.FieldNotNull(FieldUsedAt)) +} + +// HasUser applies the HasEdge predicate on the "user" edge. +func HasUser() predicate.LoginHandoff { + return predicate.LoginHandoff(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.M2O, false, UserTable, UserColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasUserWith applies the HasEdge predicate on the "user" edge with a given conditions (other predicates). +func HasUserWith(preds ...predicate.User) predicate.LoginHandoff { + return predicate.LoginHandoff(func(s *sql.Selector) { + step := newUserStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + +// And groups predicates with the AND operator between them. +func And(predicates ...predicate.LoginHandoff) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.AndPredicates(predicates...)) +} + +// Or groups predicates with the OR operator between them. +func Or(predicates ...predicate.LoginHandoff) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.OrPredicates(predicates...)) +} + +// Not applies the not operator on the given predicate. +func Not(p predicate.LoginHandoff) predicate.LoginHandoff { + return predicate.LoginHandoff(sql.NotPredicates(p)) +} diff --git a/app/controlplane/pkg/data/ent/loginhandoff_create.go b/app/controlplane/pkg/data/ent/loginhandoff_create.go new file mode 100644 index 000000000..250843ad8 --- /dev/null +++ b/app/controlplane/pkg/data/ent/loginhandoff_create.go @@ -0,0 +1,670 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/loginhandoff" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/user" + "github.com/google/uuid" +) + +// LoginHandoffCreate is the builder for creating a LoginHandoff entity. +type LoginHandoffCreate struct { + config + mutation *LoginHandoffMutation + hooks []Hook + conflict []sql.ConflictOption +} + +// SetCodeHash sets the "code_hash" field. +func (_c *LoginHandoffCreate) SetCodeHash(v string) *LoginHandoffCreate { + _c.mutation.SetCodeHash(v) + return _c +} + +// SetUserID sets the "user_id" field. +func (_c *LoginHandoffCreate) SetUserID(v uuid.UUID) *LoginHandoffCreate { + _c.mutation.SetUserID(v) + return _c +} + +// SetTokenTTLSeconds sets the "token_ttl_seconds" field. +func (_c *LoginHandoffCreate) SetTokenTTLSeconds(v int64) *LoginHandoffCreate { + _c.mutation.SetTokenTTLSeconds(v) + return _c +} + +// SetCodeChallenge sets the "code_challenge" field. +func (_c *LoginHandoffCreate) SetCodeChallenge(v string) *LoginHandoffCreate { + _c.mutation.SetCodeChallenge(v) + return _c +} + +// SetCallback sets the "callback" field. +func (_c *LoginHandoffCreate) SetCallback(v string) *LoginHandoffCreate { + _c.mutation.SetCallback(v) + return _c +} + +// SetExpiresAt sets the "expires_at" field. +func (_c *LoginHandoffCreate) SetExpiresAt(v time.Time) *LoginHandoffCreate { + _c.mutation.SetExpiresAt(v) + return _c +} + +// SetUsedAt sets the "used_at" field. +func (_c *LoginHandoffCreate) SetUsedAt(v time.Time) *LoginHandoffCreate { + _c.mutation.SetUsedAt(v) + return _c +} + +// SetNillableUsedAt sets the "used_at" field if the given value is not nil. +func (_c *LoginHandoffCreate) SetNillableUsedAt(v *time.Time) *LoginHandoffCreate { + if v != nil { + _c.SetUsedAt(*v) + } + return _c +} + +// SetID sets the "id" field. +func (_c *LoginHandoffCreate) SetID(v uuid.UUID) *LoginHandoffCreate { + _c.mutation.SetID(v) + return _c +} + +// SetNillableID sets the "id" field if the given value is not nil. +func (_c *LoginHandoffCreate) SetNillableID(v *uuid.UUID) *LoginHandoffCreate { + if v != nil { + _c.SetID(*v) + } + return _c +} + +// SetUser sets the "user" edge to the User entity. +func (_c *LoginHandoffCreate) SetUser(v *User) *LoginHandoffCreate { + return _c.SetUserID(v.ID) +} + +// Mutation returns the LoginHandoffMutation object of the builder. +func (_c *LoginHandoffCreate) Mutation() *LoginHandoffMutation { + return _c.mutation +} + +// Save creates the LoginHandoff in the database. +func (_c *LoginHandoffCreate) Save(ctx context.Context) (*LoginHandoff, error) { + _c.defaults() + return withHooks(ctx, _c.sqlSave, _c.mutation, _c.hooks) +} + +// SaveX calls Save and panics if Save returns an error. +func (_c *LoginHandoffCreate) SaveX(ctx context.Context) *LoginHandoff { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *LoginHandoffCreate) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *LoginHandoffCreate) ExecX(ctx context.Context) { + if err := _c.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_c *LoginHandoffCreate) defaults() { + if _, ok := _c.mutation.ID(); !ok { + v := loginhandoff.DefaultID() + _c.mutation.SetID(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_c *LoginHandoffCreate) check() error { + if _, ok := _c.mutation.CodeHash(); !ok { + return &ValidationError{Name: "code_hash", err: errors.New(`ent: missing required field "LoginHandoff.code_hash"`)} + } + if _, ok := _c.mutation.UserID(); !ok { + return &ValidationError{Name: "user_id", err: errors.New(`ent: missing required field "LoginHandoff.user_id"`)} + } + if _, ok := _c.mutation.TokenTTLSeconds(); !ok { + return &ValidationError{Name: "token_ttl_seconds", err: errors.New(`ent: missing required field "LoginHandoff.token_ttl_seconds"`)} + } + if _, ok := _c.mutation.CodeChallenge(); !ok { + return &ValidationError{Name: "code_challenge", err: errors.New(`ent: missing required field "LoginHandoff.code_challenge"`)} + } + if _, ok := _c.mutation.Callback(); !ok { + return &ValidationError{Name: "callback", err: errors.New(`ent: missing required field "LoginHandoff.callback"`)} + } + if _, ok := _c.mutation.ExpiresAt(); !ok { + return &ValidationError{Name: "expires_at", err: errors.New(`ent: missing required field "LoginHandoff.expires_at"`)} + } + if len(_c.mutation.UserIDs()) == 0 { + return &ValidationError{Name: "user", err: errors.New(`ent: missing required edge "LoginHandoff.user"`)} + } + return nil +} + +func (_c *LoginHandoffCreate) sqlSave(ctx context.Context) (*LoginHandoff, error) { + if err := _c.check(); err != nil { + return nil, err + } + _node, _spec := _c.createSpec() + if err := sqlgraph.CreateNode(ctx, _c.driver, _spec); err != nil { + if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return nil, err + } + if _spec.ID.Value != nil { + if id, ok := _spec.ID.Value.(*uuid.UUID); ok { + _node.ID = *id + } else if err := _node.ID.Scan(_spec.ID.Value); err != nil { + return nil, err + } + } + _c.mutation.id = &_node.ID + _c.mutation.done = true + return _node, nil +} + +func (_c *LoginHandoffCreate) createSpec() (*LoginHandoff, *sqlgraph.CreateSpec) { + var ( + _node = &LoginHandoff{config: _c.config} + _spec = sqlgraph.NewCreateSpec(loginhandoff.Table, sqlgraph.NewFieldSpec(loginhandoff.FieldID, field.TypeUUID)) + ) + _spec.OnConflict = _c.conflict + if id, ok := _c.mutation.ID(); ok { + _node.ID = id + _spec.ID.Value = &id + } + if value, ok := _c.mutation.CodeHash(); ok { + _spec.SetField(loginhandoff.FieldCodeHash, field.TypeString, value) + _node.CodeHash = value + } + if value, ok := _c.mutation.TokenTTLSeconds(); ok { + _spec.SetField(loginhandoff.FieldTokenTTLSeconds, field.TypeInt64, value) + _node.TokenTTLSeconds = value + } + if value, ok := _c.mutation.CodeChallenge(); ok { + _spec.SetField(loginhandoff.FieldCodeChallenge, field.TypeString, value) + _node.CodeChallenge = value + } + if value, ok := _c.mutation.Callback(); ok { + _spec.SetField(loginhandoff.FieldCallback, field.TypeString, value) + _node.Callback = value + } + if value, ok := _c.mutation.ExpiresAt(); ok { + _spec.SetField(loginhandoff.FieldExpiresAt, field.TypeTime, value) + _node.ExpiresAt = value + } + if value, ok := _c.mutation.UsedAt(); ok { + _spec.SetField(loginhandoff.FieldUsedAt, field.TypeTime, value) + _node.UsedAt = &value + } + if nodes := _c.mutation.UserIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: false, + Table: loginhandoff.UserTable, + Columns: []string{loginhandoff.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeUUID), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _node.UserID = nodes[0] + _spec.Edges = append(_spec.Edges, edge) + } + return _node, _spec +} + +// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause +// of the `INSERT` statement. For example: +// +// client.LoginHandoff.Create(). +// SetCodeHash(v). +// OnConflict( +// // Update the row with the new values +// // the was proposed for insertion. +// sql.ResolveWithNewValues(), +// ). +// // Override some of the fields with custom +// // update values. +// Update(func(u *ent.LoginHandoffUpsert) { +// SetCodeHash(v+v). +// }). +// Exec(ctx) +func (_c *LoginHandoffCreate) OnConflict(opts ...sql.ConflictOption) *LoginHandoffUpsertOne { + _c.conflict = opts + return &LoginHandoffUpsertOne{ + create: _c, + } +} + +// OnConflictColumns calls `OnConflict` and configures the columns +// as conflict target. Using this option is equivalent to using: +// +// client.LoginHandoff.Create(). +// OnConflict(sql.ConflictColumns(columns...)). +// Exec(ctx) +func (_c *LoginHandoffCreate) OnConflictColumns(columns ...string) *LoginHandoffUpsertOne { + _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) + return &LoginHandoffUpsertOne{ + create: _c, + } +} + +type ( + // LoginHandoffUpsertOne is the builder for "upsert"-ing + // one LoginHandoff node. + LoginHandoffUpsertOne struct { + create *LoginHandoffCreate + } + + // LoginHandoffUpsert is the "OnConflict" setter. + LoginHandoffUpsert struct { + *sql.UpdateSet + } +) + +// SetUsedAt sets the "used_at" field. +func (u *LoginHandoffUpsert) SetUsedAt(v time.Time) *LoginHandoffUpsert { + u.Set(loginhandoff.FieldUsedAt, v) + return u +} + +// UpdateUsedAt sets the "used_at" field to the value that was provided on create. +func (u *LoginHandoffUpsert) UpdateUsedAt() *LoginHandoffUpsert { + u.SetExcluded(loginhandoff.FieldUsedAt) + return u +} + +// ClearUsedAt clears the value of the "used_at" field. +func (u *LoginHandoffUpsert) ClearUsedAt() *LoginHandoffUpsert { + u.SetNull(loginhandoff.FieldUsedAt) + return u +} + +// UpdateNewValues updates the mutable fields using the new values that were set on create except the ID field. +// Using this option is equivalent to using: +// +// client.LoginHandoff.Create(). +// OnConflict( +// sql.ResolveWithNewValues(), +// sql.ResolveWith(func(u *sql.UpdateSet) { +// u.SetIgnore(loginhandoff.FieldID) +// }), +// ). +// Exec(ctx) +func (u *LoginHandoffUpsertOne) UpdateNewValues() *LoginHandoffUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) { + if _, exists := u.create.mutation.ID(); exists { + s.SetIgnore(loginhandoff.FieldID) + } + if _, exists := u.create.mutation.CodeHash(); exists { + s.SetIgnore(loginhandoff.FieldCodeHash) + } + if _, exists := u.create.mutation.UserID(); exists { + s.SetIgnore(loginhandoff.FieldUserID) + } + if _, exists := u.create.mutation.TokenTTLSeconds(); exists { + s.SetIgnore(loginhandoff.FieldTokenTTLSeconds) + } + if _, exists := u.create.mutation.CodeChallenge(); exists { + s.SetIgnore(loginhandoff.FieldCodeChallenge) + } + if _, exists := u.create.mutation.Callback(); exists { + s.SetIgnore(loginhandoff.FieldCallback) + } + if _, exists := u.create.mutation.ExpiresAt(); exists { + s.SetIgnore(loginhandoff.FieldExpiresAt) + } + })) + return u +} + +// Ignore sets each column to itself in case of conflict. +// Using this option is equivalent to using: +// +// client.LoginHandoff.Create(). +// OnConflict(sql.ResolveWithIgnore()). +// Exec(ctx) +func (u *LoginHandoffUpsertOne) Ignore() *LoginHandoffUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore()) + return u +} + +// DoNothing configures the conflict_action to `DO NOTHING`. +// Supported only by SQLite and PostgreSQL. +func (u *LoginHandoffUpsertOne) DoNothing() *LoginHandoffUpsertOne { + u.create.conflict = append(u.create.conflict, sql.DoNothing()) + return u +} + +// Update allows overriding fields `UPDATE` values. See the LoginHandoffCreate.OnConflict +// documentation for more info. +func (u *LoginHandoffUpsertOne) Update(set func(*LoginHandoffUpsert)) *LoginHandoffUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { + set(&LoginHandoffUpsert{UpdateSet: update}) + })) + return u +} + +// SetUsedAt sets the "used_at" field. +func (u *LoginHandoffUpsertOne) SetUsedAt(v time.Time) *LoginHandoffUpsertOne { + return u.Update(func(s *LoginHandoffUpsert) { + s.SetUsedAt(v) + }) +} + +// UpdateUsedAt sets the "used_at" field to the value that was provided on create. +func (u *LoginHandoffUpsertOne) UpdateUsedAt() *LoginHandoffUpsertOne { + return u.Update(func(s *LoginHandoffUpsert) { + s.UpdateUsedAt() + }) +} + +// ClearUsedAt clears the value of the "used_at" field. +func (u *LoginHandoffUpsertOne) ClearUsedAt() *LoginHandoffUpsertOne { + return u.Update(func(s *LoginHandoffUpsert) { + s.ClearUsedAt() + }) +} + +// Exec executes the query. +func (u *LoginHandoffUpsertOne) Exec(ctx context.Context) error { + if len(u.create.conflict) == 0 { + return errors.New("ent: missing options for LoginHandoffCreate.OnConflict") + } + return u.create.Exec(ctx) +} + +// ExecX is like Exec, but panics if an error occurs. +func (u *LoginHandoffUpsertOne) ExecX(ctx context.Context) { + if err := u.create.Exec(ctx); err != nil { + panic(err) + } +} + +// Exec executes the UPSERT query and returns the inserted/updated ID. +func (u *LoginHandoffUpsertOne) ID(ctx context.Context) (id uuid.UUID, err error) { + if u.create.driver.Dialect() == dialect.MySQL { + // In case of "ON CONFLICT", there is no way to get back non-numeric ID + // fields from the database since MySQL does not support the RETURNING clause. + return id, errors.New("ent: LoginHandoffUpsertOne.ID is not supported by MySQL driver. Use LoginHandoffUpsertOne.Exec instead") + } + node, err := u.create.Save(ctx) + if err != nil { + return id, err + } + return node.ID, nil +} + +// IDX is like ID, but panics if an error occurs. +func (u *LoginHandoffUpsertOne) IDX(ctx context.Context) uuid.UUID { + id, err := u.ID(ctx) + if err != nil { + panic(err) + } + return id +} + +// LoginHandoffCreateBulk is the builder for creating many LoginHandoff entities in bulk. +type LoginHandoffCreateBulk struct { + config + err error + builders []*LoginHandoffCreate + conflict []sql.ConflictOption +} + +// Save creates the LoginHandoff entities in the database. +func (_c *LoginHandoffCreateBulk) Save(ctx context.Context) ([]*LoginHandoff, error) { + if _c.err != nil { + return nil, _c.err + } + specs := make([]*sqlgraph.CreateSpec, len(_c.builders)) + nodes := make([]*LoginHandoff, len(_c.builders)) + mutators := make([]Mutator, len(_c.builders)) + for i := range _c.builders { + func(i int, root context.Context) { + builder := _c.builders[i] + builder.defaults() + var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { + mutation, ok := m.(*LoginHandoffMutation) + if !ok { + return nil, fmt.Errorf("unexpected mutation type %T", m) + } + if err := builder.check(); err != nil { + return nil, err + } + builder.mutation = mutation + var err error + nodes[i], specs[i] = builder.createSpec() + if i < len(mutators)-1 { + _, err = mutators[i+1].Mutate(root, _c.builders[i+1].mutation) + } else { + spec := &sqlgraph.BatchCreateSpec{Nodes: specs} + spec.OnConflict = _c.conflict + // Invoke the actual operation on the latest mutation in the chain. + if err = sqlgraph.BatchCreate(ctx, _c.driver, spec); err != nil { + if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + } + } + if err != nil { + return nil, err + } + mutation.id = &nodes[i].ID + mutation.done = true + return nodes[i], nil + }) + for i := len(builder.hooks) - 1; i >= 0; i-- { + mut = builder.hooks[i](mut) + } + mutators[i] = mut + }(i, ctx) + } + if len(mutators) > 0 { + if _, err := mutators[0].Mutate(ctx, _c.builders[0].mutation); err != nil { + return nil, err + } + } + return nodes, nil +} + +// SaveX is like Save, but panics if an error occurs. +func (_c *LoginHandoffCreateBulk) SaveX(ctx context.Context) []*LoginHandoff { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *LoginHandoffCreateBulk) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *LoginHandoffCreateBulk) ExecX(ctx context.Context) { + if err := _c.Exec(ctx); err != nil { + panic(err) + } +} + +// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause +// of the `INSERT` statement. For example: +// +// client.LoginHandoff.CreateBulk(builders...). +// OnConflict( +// // Update the row with the new values +// // the was proposed for insertion. +// sql.ResolveWithNewValues(), +// ). +// // Override some of the fields with custom +// // update values. +// Update(func(u *ent.LoginHandoffUpsert) { +// SetCodeHash(v+v). +// }). +// Exec(ctx) +func (_c *LoginHandoffCreateBulk) OnConflict(opts ...sql.ConflictOption) *LoginHandoffUpsertBulk { + _c.conflict = opts + return &LoginHandoffUpsertBulk{ + create: _c, + } +} + +// OnConflictColumns calls `OnConflict` and configures the columns +// as conflict target. Using this option is equivalent to using: +// +// client.LoginHandoff.Create(). +// OnConflict(sql.ConflictColumns(columns...)). +// Exec(ctx) +func (_c *LoginHandoffCreateBulk) OnConflictColumns(columns ...string) *LoginHandoffUpsertBulk { + _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) + return &LoginHandoffUpsertBulk{ + create: _c, + } +} + +// LoginHandoffUpsertBulk is the builder for "upsert"-ing +// a bulk of LoginHandoff nodes. +type LoginHandoffUpsertBulk struct { + create *LoginHandoffCreateBulk +} + +// UpdateNewValues updates the mutable fields using the new values that +// were set on create. Using this option is equivalent to using: +// +// client.LoginHandoff.Create(). +// OnConflict( +// sql.ResolveWithNewValues(), +// sql.ResolveWith(func(u *sql.UpdateSet) { +// u.SetIgnore(loginhandoff.FieldID) +// }), +// ). +// Exec(ctx) +func (u *LoginHandoffUpsertBulk) UpdateNewValues() *LoginHandoffUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) { + for _, b := range u.create.builders { + if _, exists := b.mutation.ID(); exists { + s.SetIgnore(loginhandoff.FieldID) + } + if _, exists := b.mutation.CodeHash(); exists { + s.SetIgnore(loginhandoff.FieldCodeHash) + } + if _, exists := b.mutation.UserID(); exists { + s.SetIgnore(loginhandoff.FieldUserID) + } + if _, exists := b.mutation.TokenTTLSeconds(); exists { + s.SetIgnore(loginhandoff.FieldTokenTTLSeconds) + } + if _, exists := b.mutation.CodeChallenge(); exists { + s.SetIgnore(loginhandoff.FieldCodeChallenge) + } + if _, exists := b.mutation.Callback(); exists { + s.SetIgnore(loginhandoff.FieldCallback) + } + if _, exists := b.mutation.ExpiresAt(); exists { + s.SetIgnore(loginhandoff.FieldExpiresAt) + } + } + })) + return u +} + +// Ignore sets each column to itself in case of conflict. +// Using this option is equivalent to using: +// +// client.LoginHandoff.Create(). +// OnConflict(sql.ResolveWithIgnore()). +// Exec(ctx) +func (u *LoginHandoffUpsertBulk) Ignore() *LoginHandoffUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore()) + return u +} + +// DoNothing configures the conflict_action to `DO NOTHING`. +// Supported only by SQLite and PostgreSQL. +func (u *LoginHandoffUpsertBulk) DoNothing() *LoginHandoffUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.DoNothing()) + return u +} + +// Update allows overriding fields `UPDATE` values. See the LoginHandoffCreateBulk.OnConflict +// documentation for more info. +func (u *LoginHandoffUpsertBulk) Update(set func(*LoginHandoffUpsert)) *LoginHandoffUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { + set(&LoginHandoffUpsert{UpdateSet: update}) + })) + return u +} + +// SetUsedAt sets the "used_at" field. +func (u *LoginHandoffUpsertBulk) SetUsedAt(v time.Time) *LoginHandoffUpsertBulk { + return u.Update(func(s *LoginHandoffUpsert) { + s.SetUsedAt(v) + }) +} + +// UpdateUsedAt sets the "used_at" field to the value that was provided on create. +func (u *LoginHandoffUpsertBulk) UpdateUsedAt() *LoginHandoffUpsertBulk { + return u.Update(func(s *LoginHandoffUpsert) { + s.UpdateUsedAt() + }) +} + +// ClearUsedAt clears the value of the "used_at" field. +func (u *LoginHandoffUpsertBulk) ClearUsedAt() *LoginHandoffUpsertBulk { + return u.Update(func(s *LoginHandoffUpsert) { + s.ClearUsedAt() + }) +} + +// Exec executes the query. +func (u *LoginHandoffUpsertBulk) Exec(ctx context.Context) error { + if u.create.err != nil { + return u.create.err + } + for i, b := range u.create.builders { + if len(b.conflict) != 0 { + return fmt.Errorf("ent: OnConflict was set for builder %d. Set it on the LoginHandoffCreateBulk instead", i) + } + } + if len(u.create.conflict) == 0 { + return errors.New("ent: missing options for LoginHandoffCreateBulk.OnConflict") + } + return u.create.Exec(ctx) +} + +// ExecX is like Exec, but panics if an error occurs. +func (u *LoginHandoffUpsertBulk) ExecX(ctx context.Context) { + if err := u.create.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/app/controlplane/pkg/data/ent/loginhandoff_delete.go b/app/controlplane/pkg/data/ent/loginhandoff_delete.go new file mode 100644 index 000000000..6ffe43003 --- /dev/null +++ b/app/controlplane/pkg/data/ent/loginhandoff_delete.go @@ -0,0 +1,88 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/loginhandoff" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/predicate" +) + +// LoginHandoffDelete is the builder for deleting a LoginHandoff entity. +type LoginHandoffDelete struct { + config + hooks []Hook + mutation *LoginHandoffMutation +} + +// Where appends a list predicates to the LoginHandoffDelete builder. +func (_d *LoginHandoffDelete) Where(ps ...predicate.LoginHandoff) *LoginHandoffDelete { + _d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query and returns how many vertices were deleted. +func (_d *LoginHandoffDelete) Exec(ctx context.Context) (int, error) { + return withHooks(ctx, _d.sqlExec, _d.mutation, _d.hooks) +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *LoginHandoffDelete) ExecX(ctx context.Context) int { + n, err := _d.Exec(ctx) + if err != nil { + panic(err) + } + return n +} + +func (_d *LoginHandoffDelete) sqlExec(ctx context.Context) (int, error) { + _spec := sqlgraph.NewDeleteSpec(loginhandoff.Table, sqlgraph.NewFieldSpec(loginhandoff.FieldID, field.TypeUUID)) + if ps := _d.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + affected, err := sqlgraph.DeleteNodes(ctx, _d.driver, _spec) + if err != nil && sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + _d.mutation.done = true + return affected, err +} + +// LoginHandoffDeleteOne is the builder for deleting a single LoginHandoff entity. +type LoginHandoffDeleteOne struct { + _d *LoginHandoffDelete +} + +// Where appends a list predicates to the LoginHandoffDelete builder. +func (_d *LoginHandoffDeleteOne) Where(ps ...predicate.LoginHandoff) *LoginHandoffDeleteOne { + _d._d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query. +func (_d *LoginHandoffDeleteOne) Exec(ctx context.Context) error { + n, err := _d._d.Exec(ctx) + switch { + case err != nil: + return err + case n == 0: + return &NotFoundError{loginhandoff.Label} + default: + return nil + } +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *LoginHandoffDeleteOne) ExecX(ctx context.Context) { + if err := _d.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/app/controlplane/pkg/data/ent/loginhandoff_query.go b/app/controlplane/pkg/data/ent/loginhandoff_query.go new file mode 100644 index 000000000..c5c70d3c2 --- /dev/null +++ b/app/controlplane/pkg/data/ent/loginhandoff_query.go @@ -0,0 +1,657 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + "fmt" + "math" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/loginhandoff" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/predicate" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/user" + "github.com/google/uuid" +) + +// LoginHandoffQuery is the builder for querying LoginHandoff entities. +type LoginHandoffQuery struct { + config + ctx *QueryContext + order []loginhandoff.OrderOption + inters []Interceptor + predicates []predicate.LoginHandoff + withUser *UserQuery + modifiers []func(*sql.Selector) + // intermediate query (i.e. traversal path). + sql *sql.Selector + path func(context.Context) (*sql.Selector, error) +} + +// Where adds a new predicate for the LoginHandoffQuery builder. +func (_q *LoginHandoffQuery) Where(ps ...predicate.LoginHandoff) *LoginHandoffQuery { + _q.predicates = append(_q.predicates, ps...) + return _q +} + +// Limit the number of records to be returned by this query. +func (_q *LoginHandoffQuery) Limit(limit int) *LoginHandoffQuery { + _q.ctx.Limit = &limit + return _q +} + +// Offset to start from. +func (_q *LoginHandoffQuery) Offset(offset int) *LoginHandoffQuery { + _q.ctx.Offset = &offset + return _q +} + +// Unique configures the query builder to filter duplicate records on query. +// By default, unique is set to true, and can be disabled using this method. +func (_q *LoginHandoffQuery) Unique(unique bool) *LoginHandoffQuery { + _q.ctx.Unique = &unique + return _q +} + +// Order specifies how the records should be ordered. +func (_q *LoginHandoffQuery) Order(o ...loginhandoff.OrderOption) *LoginHandoffQuery { + _q.order = append(_q.order, o...) + return _q +} + +// QueryUser chains the current query on the "user" edge. +func (_q *LoginHandoffQuery) QueryUser() *UserQuery { + query := (&UserClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(loginhandoff.Table, loginhandoff.FieldID, selector), + sqlgraph.To(user.Table, user.FieldID), + sqlgraph.Edge(sqlgraph.M2O, false, loginhandoff.UserTable, loginhandoff.UserColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + +// First returns the first LoginHandoff entity from the query. +// Returns a *NotFoundError when no LoginHandoff was found. +func (_q *LoginHandoffQuery) First(ctx context.Context) (*LoginHandoff, error) { + nodes, err := _q.Limit(1).All(setContextOp(ctx, _q.ctx, ent.OpQueryFirst)) + if err != nil { + return nil, err + } + if len(nodes) == 0 { + return nil, &NotFoundError{loginhandoff.Label} + } + return nodes[0], nil +} + +// FirstX is like First, but panics if an error occurs. +func (_q *LoginHandoffQuery) FirstX(ctx context.Context) *LoginHandoff { + node, err := _q.First(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return node +} + +// FirstID returns the first LoginHandoff ID from the query. +// Returns a *NotFoundError when no LoginHandoff ID was found. +func (_q *LoginHandoffQuery) FirstID(ctx context.Context) (id uuid.UUID, err error) { + var ids []uuid.UUID + if ids, err = _q.Limit(1).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryFirstID)); err != nil { + return + } + if len(ids) == 0 { + err = &NotFoundError{loginhandoff.Label} + return + } + return ids[0], nil +} + +// FirstIDX is like FirstID, but panics if an error occurs. +func (_q *LoginHandoffQuery) FirstIDX(ctx context.Context) uuid.UUID { + id, err := _q.FirstID(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return id +} + +// Only returns a single LoginHandoff entity found by the query, ensuring it only returns one. +// Returns a *NotSingularError when more than one LoginHandoff entity is found. +// Returns a *NotFoundError when no LoginHandoff entities are found. +func (_q *LoginHandoffQuery) Only(ctx context.Context) (*LoginHandoff, error) { + nodes, err := _q.Limit(2).All(setContextOp(ctx, _q.ctx, ent.OpQueryOnly)) + if err != nil { + return nil, err + } + switch len(nodes) { + case 1: + return nodes[0], nil + case 0: + return nil, &NotFoundError{loginhandoff.Label} + default: + return nil, &NotSingularError{loginhandoff.Label} + } +} + +// OnlyX is like Only, but panics if an error occurs. +func (_q *LoginHandoffQuery) OnlyX(ctx context.Context) *LoginHandoff { + node, err := _q.Only(ctx) + if err != nil { + panic(err) + } + return node +} + +// OnlyID is like Only, but returns the only LoginHandoff ID in the query. +// Returns a *NotSingularError when more than one LoginHandoff ID is found. +// Returns a *NotFoundError when no entities are found. +func (_q *LoginHandoffQuery) OnlyID(ctx context.Context) (id uuid.UUID, err error) { + var ids []uuid.UUID + if ids, err = _q.Limit(2).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryOnlyID)); err != nil { + return + } + switch len(ids) { + case 1: + id = ids[0] + case 0: + err = &NotFoundError{loginhandoff.Label} + default: + err = &NotSingularError{loginhandoff.Label} + } + return +} + +// OnlyIDX is like OnlyID, but panics if an error occurs. +func (_q *LoginHandoffQuery) OnlyIDX(ctx context.Context) uuid.UUID { + id, err := _q.OnlyID(ctx) + if err != nil { + panic(err) + } + return id +} + +// All executes the query and returns a list of LoginHandoffs. +func (_q *LoginHandoffQuery) All(ctx context.Context) ([]*LoginHandoff, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll) + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + qr := querierAll[[]*LoginHandoff, *LoginHandoffQuery]() + return withInterceptors[[]*LoginHandoff](ctx, _q, qr, _q.inters) +} + +// AllX is like All, but panics if an error occurs. +func (_q *LoginHandoffQuery) AllX(ctx context.Context) []*LoginHandoff { + nodes, err := _q.All(ctx) + if err != nil { + panic(err) + } + return nodes +} + +// IDs executes the query and returns a list of LoginHandoff IDs. +func (_q *LoginHandoffQuery) IDs(ctx context.Context) (ids []uuid.UUID, err error) { + if _q.ctx.Unique == nil && _q.path != nil { + _q.Unique(true) + } + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryIDs) + if err = _q.Select(loginhandoff.FieldID).Scan(ctx, &ids); err != nil { + return nil, err + } + return ids, nil +} + +// IDsX is like IDs, but panics if an error occurs. +func (_q *LoginHandoffQuery) IDsX(ctx context.Context) []uuid.UUID { + ids, err := _q.IDs(ctx) + if err != nil { + panic(err) + } + return ids +} + +// Count returns the count of the given query. +func (_q *LoginHandoffQuery) Count(ctx context.Context) (int, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryCount) + if err := _q.prepareQuery(ctx); err != nil { + return 0, err + } + return withInterceptors[int](ctx, _q, querierCount[*LoginHandoffQuery](), _q.inters) +} + +// CountX is like Count, but panics if an error occurs. +func (_q *LoginHandoffQuery) CountX(ctx context.Context) int { + count, err := _q.Count(ctx) + if err != nil { + panic(err) + } + return count +} + +// Exist returns true if the query has elements in the graph. +func (_q *LoginHandoffQuery) Exist(ctx context.Context) (bool, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryExist) + switch _, err := _q.FirstID(ctx); { + case IsNotFound(err): + return false, nil + case err != nil: + return false, fmt.Errorf("ent: check existence: %w", err) + default: + return true, nil + } +} + +// ExistX is like Exist, but panics if an error occurs. +func (_q *LoginHandoffQuery) ExistX(ctx context.Context) bool { + exist, err := _q.Exist(ctx) + if err != nil { + panic(err) + } + return exist +} + +// Clone returns a duplicate of the LoginHandoffQuery builder, including all associated steps. It can be +// used to prepare common query builders and use them differently after the clone is made. +func (_q *LoginHandoffQuery) Clone() *LoginHandoffQuery { + if _q == nil { + return nil + } + return &LoginHandoffQuery{ + config: _q.config, + ctx: _q.ctx.Clone(), + order: append([]loginhandoff.OrderOption{}, _q.order...), + inters: append([]Interceptor{}, _q.inters...), + predicates: append([]predicate.LoginHandoff{}, _q.predicates...), + withUser: _q.withUser.Clone(), + // clone intermediate query. + sql: _q.sql.Clone(), + path: _q.path, + modifiers: append([]func(*sql.Selector){}, _q.modifiers...), + } +} + +// WithUser tells the query-builder to eager-load the nodes that are connected to +// the "user" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *LoginHandoffQuery) WithUser(opts ...func(*UserQuery)) *LoginHandoffQuery { + query := (&UserClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withUser = query + return _q +} + +// GroupBy is used to group vertices by one or more fields/columns. +// It is often used with aggregate functions, like: count, max, mean, min, sum. +// +// Example: +// +// var v []struct { +// CodeHash string `json:"code_hash,omitempty"` +// Count int `json:"count,omitempty"` +// } +// +// client.LoginHandoff.Query(). +// GroupBy(loginhandoff.FieldCodeHash). +// Aggregate(ent.Count()). +// Scan(ctx, &v) +func (_q *LoginHandoffQuery) GroupBy(field string, fields ...string) *LoginHandoffGroupBy { + _q.ctx.Fields = append([]string{field}, fields...) + grbuild := &LoginHandoffGroupBy{build: _q} + grbuild.flds = &_q.ctx.Fields + grbuild.label = loginhandoff.Label + grbuild.scan = grbuild.Scan + return grbuild +} + +// Select allows the selection one or more fields/columns for the given query, +// instead of selecting all fields in the entity. +// +// Example: +// +// var v []struct { +// CodeHash string `json:"code_hash,omitempty"` +// } +// +// client.LoginHandoff.Query(). +// Select(loginhandoff.FieldCodeHash). +// Scan(ctx, &v) +func (_q *LoginHandoffQuery) Select(fields ...string) *LoginHandoffSelect { + _q.ctx.Fields = append(_q.ctx.Fields, fields...) + sbuild := &LoginHandoffSelect{LoginHandoffQuery: _q} + sbuild.label = loginhandoff.Label + sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan + return sbuild +} + +// Aggregate returns a LoginHandoffSelect configured with the given aggregations. +func (_q *LoginHandoffQuery) Aggregate(fns ...AggregateFunc) *LoginHandoffSelect { + return _q.Select().Aggregate(fns...) +} + +func (_q *LoginHandoffQuery) prepareQuery(ctx context.Context) error { + for _, inter := range _q.inters { + if inter == nil { + return fmt.Errorf("ent: uninitialized interceptor (forgotten import ent/runtime?)") + } + if trv, ok := inter.(Traverser); ok { + if err := trv.Traverse(ctx, _q); err != nil { + return err + } + } + } + for _, f := range _q.ctx.Fields { + if !loginhandoff.ValidColumn(f) { + return &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)} + } + } + if _q.path != nil { + prev, err := _q.path(ctx) + if err != nil { + return err + } + _q.sql = prev + } + return nil +} + +func (_q *LoginHandoffQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*LoginHandoff, error) { + var ( + nodes = []*LoginHandoff{} + _spec = _q.querySpec() + loadedTypes = [1]bool{ + _q.withUser != nil, + } + ) + _spec.ScanValues = func(columns []string) ([]any, error) { + return (*LoginHandoff).scanValues(nil, columns) + } + _spec.Assign = func(columns []string, values []any) error { + node := &LoginHandoff{config: _q.config} + nodes = append(nodes, node) + node.Edges.loadedTypes = loadedTypes + return node.assignValues(columns, values) + } + if len(_q.modifiers) > 0 { + _spec.Modifiers = _q.modifiers + } + for i := range hooks { + hooks[i](ctx, _spec) + } + if err := sqlgraph.QueryNodes(ctx, _q.driver, _spec); err != nil { + return nil, err + } + if len(nodes) == 0 { + return nodes, nil + } + if query := _q.withUser; query != nil { + if err := _q.loadUser(ctx, query, nodes, nil, + func(n *LoginHandoff, e *User) { n.Edges.User = e }); err != nil { + return nil, err + } + } + return nodes, nil +} + +func (_q *LoginHandoffQuery) loadUser(ctx context.Context, query *UserQuery, nodes []*LoginHandoff, init func(*LoginHandoff), assign func(*LoginHandoff, *User)) error { + ids := make([]uuid.UUID, 0, len(nodes)) + nodeids := make(map[uuid.UUID][]*LoginHandoff) + for i := range nodes { + fk := nodes[i].UserID + if _, ok := nodeids[fk]; !ok { + ids = append(ids, fk) + } + nodeids[fk] = append(nodeids[fk], nodes[i]) + } + if len(ids) == 0 { + return nil + } + query.Where(user.IDIn(ids...)) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + nodes, ok := nodeids[n.ID] + if !ok { + return fmt.Errorf(`unexpected foreign-key "user_id" returned %v`, n.ID) + } + for i := range nodes { + assign(nodes[i], n) + } + } + return nil +} + +func (_q *LoginHandoffQuery) sqlCount(ctx context.Context) (int, error) { + _spec := _q.querySpec() + if len(_q.modifiers) > 0 { + _spec.Modifiers = _q.modifiers + } + _spec.Node.Columns = _q.ctx.Fields + if len(_q.ctx.Fields) > 0 { + _spec.Unique = _q.ctx.Unique != nil && *_q.ctx.Unique + } + return sqlgraph.CountNodes(ctx, _q.driver, _spec) +} + +func (_q *LoginHandoffQuery) querySpec() *sqlgraph.QuerySpec { + _spec := sqlgraph.NewQuerySpec(loginhandoff.Table, loginhandoff.Columns, sqlgraph.NewFieldSpec(loginhandoff.FieldID, field.TypeUUID)) + _spec.From = _q.sql + if unique := _q.ctx.Unique; unique != nil { + _spec.Unique = *unique + } else if _q.path != nil { + _spec.Unique = true + } + if fields := _q.ctx.Fields; len(fields) > 0 { + _spec.Node.Columns = make([]string, 0, len(fields)) + _spec.Node.Columns = append(_spec.Node.Columns, loginhandoff.FieldID) + for i := range fields { + if fields[i] != loginhandoff.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, fields[i]) + } + } + if _q.withUser != nil { + _spec.Node.AddColumnOnce(loginhandoff.FieldUserID) + } + } + if ps := _q.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if limit := _q.ctx.Limit; limit != nil { + _spec.Limit = *limit + } + if offset := _q.ctx.Offset; offset != nil { + _spec.Offset = *offset + } + if ps := _q.order; len(ps) > 0 { + _spec.Order = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + return _spec +} + +func (_q *LoginHandoffQuery) sqlQuery(ctx context.Context) *sql.Selector { + builder := sql.Dialect(_q.driver.Dialect()) + t1 := builder.Table(loginhandoff.Table) + columns := _q.ctx.Fields + if len(columns) == 0 { + columns = loginhandoff.Columns + } + selector := builder.Select(t1.Columns(columns...)...).From(t1) + if _q.sql != nil { + selector = _q.sql + selector.Select(selector.Columns(columns...)...) + } + if _q.ctx.Unique != nil && *_q.ctx.Unique { + selector.Distinct() + } + for _, m := range _q.modifiers { + m(selector) + } + for _, p := range _q.predicates { + p(selector) + } + for _, p := range _q.order { + p(selector) + } + if offset := _q.ctx.Offset; offset != nil { + // limit is mandatory for offset clause. We start + // with default value, and override it below if needed. + selector.Offset(*offset).Limit(math.MaxInt32) + } + if limit := _q.ctx.Limit; limit != nil { + selector.Limit(*limit) + } + return selector +} + +// ForUpdate locks the selected rows against concurrent updates, and prevent them from being +// updated, deleted or "selected ... for update" by other sessions, until the transaction is +// either committed or rolled-back. +func (_q *LoginHandoffQuery) ForUpdate(opts ...sql.LockOption) *LoginHandoffQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForUpdate(opts...) + }) + return _q +} + +// ForShare behaves similarly to ForUpdate, except that it acquires a shared mode lock +// on any rows that are read. Other sessions can read the rows, but cannot modify them +// until your transaction commits. +func (_q *LoginHandoffQuery) ForShare(opts ...sql.LockOption) *LoginHandoffQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + +// Modify adds a query modifier for attaching custom logic to queries. +func (_q *LoginHandoffQuery) Modify(modifiers ...func(s *sql.Selector)) *LoginHandoffSelect { + _q.modifiers = append(_q.modifiers, modifiers...) + return _q.Select() +} + +// LoginHandoffGroupBy is the group-by builder for LoginHandoff entities. +type LoginHandoffGroupBy struct { + selector + build *LoginHandoffQuery +} + +// Aggregate adds the given aggregation functions to the group-by query. +func (_g *LoginHandoffGroupBy) Aggregate(fns ...AggregateFunc) *LoginHandoffGroupBy { + _g.fns = append(_g.fns, fns...) + return _g +} + +// Scan applies the selector query and scans the result into the given value. +func (_g *LoginHandoffGroupBy) Scan(ctx context.Context, v any) error { + ctx = setContextOp(ctx, _g.build.ctx, ent.OpQueryGroupBy) + if err := _g.build.prepareQuery(ctx); err != nil { + return err + } + return scanWithInterceptors[*LoginHandoffQuery, *LoginHandoffGroupBy](ctx, _g.build, _g, _g.build.inters, v) +} + +func (_g *LoginHandoffGroupBy) sqlScan(ctx context.Context, root *LoginHandoffQuery, v any) error { + selector := root.sqlQuery(ctx).Select() + aggregation := make([]string, 0, len(_g.fns)) + for _, fn := range _g.fns { + aggregation = append(aggregation, fn(selector)) + } + if len(selector.SelectedColumns()) == 0 { + columns := make([]string, 0, len(*_g.flds)+len(_g.fns)) + for _, f := range *_g.flds { + columns = append(columns, selector.C(f)) + } + columns = append(columns, aggregation...) + selector.Select(columns...) + } + selector.GroupBy(selector.Columns(*_g.flds...)...) + if err := selector.Err(); err != nil { + return err + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := _g.build.driver.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + return sql.ScanSlice(rows, v) +} + +// LoginHandoffSelect is the builder for selecting fields of LoginHandoff entities. +type LoginHandoffSelect struct { + *LoginHandoffQuery + selector +} + +// Aggregate adds the given aggregation functions to the selector query. +func (_s *LoginHandoffSelect) Aggregate(fns ...AggregateFunc) *LoginHandoffSelect { + _s.fns = append(_s.fns, fns...) + return _s +} + +// Scan applies the selector query and scans the result into the given value. +func (_s *LoginHandoffSelect) Scan(ctx context.Context, v any) error { + ctx = setContextOp(ctx, _s.ctx, ent.OpQuerySelect) + if err := _s.prepareQuery(ctx); err != nil { + return err + } + return scanWithInterceptors[*LoginHandoffQuery, *LoginHandoffSelect](ctx, _s.LoginHandoffQuery, _s, _s.inters, v) +} + +func (_s *LoginHandoffSelect) sqlScan(ctx context.Context, root *LoginHandoffQuery, v any) error { + selector := root.sqlQuery(ctx) + aggregation := make([]string, 0, len(_s.fns)) + for _, fn := range _s.fns { + aggregation = append(aggregation, fn(selector)) + } + switch n := len(*_s.selector.flds); { + case n == 0 && len(aggregation) > 0: + selector.Select(aggregation...) + case n != 0 && len(aggregation) > 0: + selector.AppendSelect(aggregation...) + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := _s.driver.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + return sql.ScanSlice(rows, v) +} + +// Modify adds a query modifier for attaching custom logic to queries. +func (_s *LoginHandoffSelect) Modify(modifiers ...func(s *sql.Selector)) *LoginHandoffSelect { + _s.modifiers = append(_s.modifiers, modifiers...) + return _s +} diff --git a/app/controlplane/pkg/data/ent/loginhandoff_update.go b/app/controlplane/pkg/data/ent/loginhandoff_update.go new file mode 100644 index 000000000..2c888dcc4 --- /dev/null +++ b/app/controlplane/pkg/data/ent/loginhandoff_update.go @@ -0,0 +1,266 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/loginhandoff" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/predicate" +) + +// LoginHandoffUpdate is the builder for updating LoginHandoff entities. +type LoginHandoffUpdate struct { + config + hooks []Hook + mutation *LoginHandoffMutation + modifiers []func(*sql.UpdateBuilder) +} + +// Where appends a list predicates to the LoginHandoffUpdate builder. +func (_u *LoginHandoffUpdate) Where(ps ...predicate.LoginHandoff) *LoginHandoffUpdate { + _u.mutation.Where(ps...) + return _u +} + +// SetUsedAt sets the "used_at" field. +func (_u *LoginHandoffUpdate) SetUsedAt(v time.Time) *LoginHandoffUpdate { + _u.mutation.SetUsedAt(v) + return _u +} + +// SetNillableUsedAt sets the "used_at" field if the given value is not nil. +func (_u *LoginHandoffUpdate) SetNillableUsedAt(v *time.Time) *LoginHandoffUpdate { + if v != nil { + _u.SetUsedAt(*v) + } + return _u +} + +// ClearUsedAt clears the value of the "used_at" field. +func (_u *LoginHandoffUpdate) ClearUsedAt() *LoginHandoffUpdate { + _u.mutation.ClearUsedAt() + return _u +} + +// Mutation returns the LoginHandoffMutation object of the builder. +func (_u *LoginHandoffUpdate) Mutation() *LoginHandoffMutation { + return _u.mutation +} + +// Save executes the query and returns the number of nodes affected by the update operation. +func (_u *LoginHandoffUpdate) Save(ctx context.Context) (int, error) { + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *LoginHandoffUpdate) SaveX(ctx context.Context) int { + affected, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return affected +} + +// Exec executes the query. +func (_u *LoginHandoffUpdate) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *LoginHandoffUpdate) ExecX(ctx context.Context) { + if err := _u.Exec(ctx); err != nil { + panic(err) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_u *LoginHandoffUpdate) check() error { + if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { + return errors.New(`ent: clearing a required unique edge "LoginHandoff.user"`) + } + return nil +} + +// Modify adds a statement modifier for attaching custom logic to the UPDATE statement. +func (_u *LoginHandoffUpdate) Modify(modifiers ...func(u *sql.UpdateBuilder)) *LoginHandoffUpdate { + _u.modifiers = append(_u.modifiers, modifiers...) + return _u +} + +func (_u *LoginHandoffUpdate) sqlSave(ctx context.Context) (_node int, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(loginhandoff.Table, loginhandoff.Columns, sqlgraph.NewFieldSpec(loginhandoff.FieldID, field.TypeUUID)) + if ps := _u.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if value, ok := _u.mutation.UsedAt(); ok { + _spec.SetField(loginhandoff.FieldUsedAt, field.TypeTime, value) + } + if _u.mutation.UsedAtCleared() { + _spec.ClearField(loginhandoff.FieldUsedAt, field.TypeTime) + } + _spec.AddModifiers(_u.modifiers...) + if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { + if _, ok := err.(*sqlgraph.NotFoundError); ok { + err = &NotFoundError{loginhandoff.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return 0, err + } + _u.mutation.done = true + return _node, nil +} + +// LoginHandoffUpdateOne is the builder for updating a single LoginHandoff entity. +type LoginHandoffUpdateOne struct { + config + fields []string + hooks []Hook + mutation *LoginHandoffMutation + modifiers []func(*sql.UpdateBuilder) +} + +// SetUsedAt sets the "used_at" field. +func (_u *LoginHandoffUpdateOne) SetUsedAt(v time.Time) *LoginHandoffUpdateOne { + _u.mutation.SetUsedAt(v) + return _u +} + +// SetNillableUsedAt sets the "used_at" field if the given value is not nil. +func (_u *LoginHandoffUpdateOne) SetNillableUsedAt(v *time.Time) *LoginHandoffUpdateOne { + if v != nil { + _u.SetUsedAt(*v) + } + return _u +} + +// ClearUsedAt clears the value of the "used_at" field. +func (_u *LoginHandoffUpdateOne) ClearUsedAt() *LoginHandoffUpdateOne { + _u.mutation.ClearUsedAt() + return _u +} + +// Mutation returns the LoginHandoffMutation object of the builder. +func (_u *LoginHandoffUpdateOne) Mutation() *LoginHandoffMutation { + return _u.mutation +} + +// Where appends a list predicates to the LoginHandoffUpdate builder. +func (_u *LoginHandoffUpdateOne) Where(ps ...predicate.LoginHandoff) *LoginHandoffUpdateOne { + _u.mutation.Where(ps...) + return _u +} + +// Select allows selecting one or more fields (columns) of the returned entity. +// The default is selecting all fields defined in the entity schema. +func (_u *LoginHandoffUpdateOne) Select(field string, fields ...string) *LoginHandoffUpdateOne { + _u.fields = append([]string{field}, fields...) + return _u +} + +// Save executes the query and returns the updated LoginHandoff entity. +func (_u *LoginHandoffUpdateOne) Save(ctx context.Context) (*LoginHandoff, error) { + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *LoginHandoffUpdateOne) SaveX(ctx context.Context) *LoginHandoff { + node, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return node +} + +// Exec executes the query on the entity. +func (_u *LoginHandoffUpdateOne) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *LoginHandoffUpdateOne) ExecX(ctx context.Context) { + if err := _u.Exec(ctx); err != nil { + panic(err) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_u *LoginHandoffUpdateOne) check() error { + if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { + return errors.New(`ent: clearing a required unique edge "LoginHandoff.user"`) + } + return nil +} + +// Modify adds a statement modifier for attaching custom logic to the UPDATE statement. +func (_u *LoginHandoffUpdateOne) Modify(modifiers ...func(u *sql.UpdateBuilder)) *LoginHandoffUpdateOne { + _u.modifiers = append(_u.modifiers, modifiers...) + return _u +} + +func (_u *LoginHandoffUpdateOne) sqlSave(ctx context.Context) (_node *LoginHandoff, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(loginhandoff.Table, loginhandoff.Columns, sqlgraph.NewFieldSpec(loginhandoff.FieldID, field.TypeUUID)) + id, ok := _u.mutation.ID() + if !ok { + return nil, &ValidationError{Name: "id", err: errors.New(`ent: missing "LoginHandoff.id" for update`)} + } + _spec.Node.ID.Value = id + if fields := _u.fields; len(fields) > 0 { + _spec.Node.Columns = make([]string, 0, len(fields)) + _spec.Node.Columns = append(_spec.Node.Columns, loginhandoff.FieldID) + for _, f := range fields { + if !loginhandoff.ValidColumn(f) { + return nil, &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)} + } + if f != loginhandoff.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, f) + } + } + } + if ps := _u.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if value, ok := _u.mutation.UsedAt(); ok { + _spec.SetField(loginhandoff.FieldUsedAt, field.TypeTime, value) + } + if _u.mutation.UsedAtCleared() { + _spec.ClearField(loginhandoff.FieldUsedAt, field.TypeTime) + } + _spec.AddModifiers(_u.modifiers...) + _node = &LoginHandoff{config: _u.config} + _spec.Assign = _node.assignValues + _spec.ScanValues = _node.scanValues + if err = sqlgraph.UpdateNode(ctx, _u.driver, _spec); err != nil { + if _, ok := err.(*sqlgraph.NotFoundError); ok { + err = &NotFoundError{loginhandoff.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return nil, err + } + _u.mutation.done = true + return _node, nil +} diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20261015173225.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20261015173225.sql new file mode 100644 index 000000000..bf5bc3f27 --- /dev/null +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20261015173225.sql @@ -0,0 +1,17 @@ +-- Create "login_handoffs" table +CREATE TABLE "login_handoffs" ( + "id" uuid NOT NULL, + "code_hash" character varying NOT NULL, + "token_ttl_seconds" bigint NOT NULL, + "code_challenge" character varying NOT NULL, + "callback" character varying NOT NULL, + "expires_at" timestamptz NOT NULL, + "used_at" timestamptz NULL, + "user_id" uuid NOT NULL, + PRIMARY KEY ("id"), + CONSTRAINT "login_handoffs_users_user" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON UPDATE NO ACTION ON DELETE CASCADE +); +-- Create index "login_handoffs_code_hash_key" to table: "login_handoffs" +CREATE UNIQUE INDEX "login_handoffs_code_hash_key" ON "login_handoffs" ("code_hash"); +-- Create index "loginhandoff_expires_at" to table: "login_handoffs" +CREATE INDEX "loginhandoff_expires_at" ON "login_handoffs" ("expires_at"); diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum index 802fbb50c..ad67be2ba 100644 --- a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum +++ b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:4xaAYBzvZ7V+L3cBo8BTrLWTuOvENuqZLf1BYcGza1E= +h1:sz67a3W+tAr+VPRPPJ48+ancduf0og/7OrQnz+ECdyk= 20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M= 20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g= 20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI= @@ -143,3 +143,4 @@ h1:4xaAYBzvZ7V+L3cBo8BTrLWTuOvENuqZLf1BYcGza1E= 20260929114226.sql h1:i1wXCIf0gb5fhHkrEj7QAFaJ4LyghZ9+jkSacc1QN2w= 20260929114227.sql h1:oulkhKH6cKhFva/5mYx6aSaFarM6YgeuTh0Plw8X38E= 20260930160057.sql h1:rKEj/76c/TiBjl/loP4Bld1BPMPTBlCWhaWvN/pJaRY= +20261015173225.sql h1:s5wTFJqcXx+cGUPpzyY93wP/IuizLaQ/9rP/VqtamRE= diff --git a/app/controlplane/pkg/data/ent/migrate/schema.go b/app/controlplane/pkg/data/ent/migrate/schema.go index 561e108ab..2f9a38801 100644 --- a/app/controlplane/pkg/data/ent/migrate/schema.go +++ b/app/controlplane/pkg/data/ent/migrate/schema.go @@ -354,6 +354,38 @@ var ( }, }, } + // LoginHandoffsColumns holds the columns for the "login_handoffs" table. + LoginHandoffsColumns = []*schema.Column{ + {Name: "id", Type: field.TypeUUID, Unique: true}, + {Name: "code_hash", Type: field.TypeString, Unique: true}, + {Name: "token_ttl_seconds", Type: field.TypeInt64}, + {Name: "code_challenge", Type: field.TypeString}, + {Name: "callback", Type: field.TypeString}, + {Name: "expires_at", Type: field.TypeTime}, + {Name: "used_at", Type: field.TypeTime, Nullable: true}, + {Name: "user_id", Type: field.TypeUUID}, + } + // LoginHandoffsTable holds the schema information for the "login_handoffs" table. + LoginHandoffsTable = &schema.Table{ + Name: "login_handoffs", + Columns: LoginHandoffsColumns, + PrimaryKey: []*schema.Column{LoginHandoffsColumns[0]}, + ForeignKeys: []*schema.ForeignKey{ + { + Symbol: "login_handoffs_users_user", + Columns: []*schema.Column{LoginHandoffsColumns[7]}, + RefColumns: []*schema.Column{UsersColumns[0]}, + OnDelete: schema.Cascade, + }, + }, + Indexes: []*schema.Index{ + { + Name: "loginhandoff_expires_at", + Unique: false, + Columns: []*schema.Column{LoginHandoffsColumns[5]}, + }, + }, + } // MembershipsColumns holds the columns for the "memberships" table. MembershipsColumns = []*schema.Column{ {Name: "id", Type: field.TypeUUID, Unique: true}, @@ -1007,6 +1039,7 @@ var ( GroupMembershipsTable, IntegrationsTable, IntegrationAttachmentsTable, + LoginHandoffsTable, MembershipsTable, OrgInvitationsTable, OrganizationsTable, @@ -1040,6 +1073,7 @@ func init() { IntegrationsTable.ForeignKeys[0].RefTable = OrganizationsTable IntegrationAttachmentsTable.ForeignKeys[0].RefTable = IntegrationsTable IntegrationAttachmentsTable.ForeignKeys[1].RefTable = WorkflowsTable + LoginHandoffsTable.ForeignKeys[0].RefTable = UsersTable MembershipsTable.ForeignKeys[0].RefTable = MembershipsTable MembershipsTable.ForeignKeys[1].RefTable = OrganizationsTable MembershipsTable.ForeignKeys[2].RefTable = UsersTable diff --git a/app/controlplane/pkg/data/ent/mutation.go b/app/controlplane/pkg/data/ent/mutation.go index ee4e54a8d..8a7ef8aed 100644 --- a/app/controlplane/pkg/data/ent/mutation.go +++ b/app/controlplane/pkg/data/ent/mutation.go @@ -21,6 +21,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/groupmembership" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/integration" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/integrationattachment" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/loginhandoff" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/membership" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/organization" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/orginvitation" @@ -56,6 +57,7 @@ const ( TypeGroupMembership = "GroupMembership" TypeIntegration = "Integration" TypeIntegrationAttachment = "IntegrationAttachment" + TypeLoginHandoff = "LoginHandoff" TypeMembership = "Membership" TypeOrgInvitation = "OrgInvitation" TypeOrganization = "Organization" @@ -7313,6 +7315,774 @@ func (m *IntegrationAttachmentMutation) ResetEdge(name string) error { return fmt.Errorf("unknown IntegrationAttachment edge %s", name) } +// LoginHandoffMutation represents an operation that mutates the LoginHandoff nodes in the graph. +type LoginHandoffMutation struct { + config + op Op + typ string + id *uuid.UUID + code_hash *string + token_ttl_seconds *int64 + addtoken_ttl_seconds *int64 + code_challenge *string + callback *string + expires_at *time.Time + used_at *time.Time + clearedFields map[string]struct{} + user *uuid.UUID + cleareduser bool + done bool + oldValue func(context.Context) (*LoginHandoff, error) + predicates []predicate.LoginHandoff +} + +var _ ent.Mutation = (*LoginHandoffMutation)(nil) + +// loginhandoffOption allows management of the mutation configuration using functional options. +type loginhandoffOption func(*LoginHandoffMutation) + +// newLoginHandoffMutation creates new mutation for the LoginHandoff entity. +func newLoginHandoffMutation(c config, op Op, opts ...loginhandoffOption) *LoginHandoffMutation { + m := &LoginHandoffMutation{ + config: c, + op: op, + typ: TypeLoginHandoff, + clearedFields: make(map[string]struct{}), + } + for _, opt := range opts { + opt(m) + } + return m +} + +// withLoginHandoffID sets the ID field of the mutation. +func withLoginHandoffID(id uuid.UUID) loginhandoffOption { + return func(m *LoginHandoffMutation) { + var ( + err error + once sync.Once + value *LoginHandoff + ) + m.oldValue = func(ctx context.Context) (*LoginHandoff, error) { + once.Do(func() { + if m.done { + err = errors.New("querying old values post mutation is not allowed") + } else { + value, err = m.Client().LoginHandoff.Get(ctx, id) + } + }) + return value, err + } + m.id = &id + } +} + +// withLoginHandoff sets the old LoginHandoff of the mutation. +func withLoginHandoff(node *LoginHandoff) loginhandoffOption { + return func(m *LoginHandoffMutation) { + m.oldValue = func(context.Context) (*LoginHandoff, error) { + return node, nil + } + m.id = &node.ID + } +} + +// Client returns a new `ent.Client` from the mutation. If the mutation was +// executed in a transaction (ent.Tx), a transactional client is returned. +func (m LoginHandoffMutation) Client() *Client { + client := &Client{config: m.config} + client.init() + return client +} + +// Tx returns an `ent.Tx` for mutations that were executed in transactions; +// it returns an error otherwise. +func (m LoginHandoffMutation) Tx() (*Tx, error) { + if _, ok := m.driver.(*txDriver); !ok { + return nil, errors.New("ent: mutation is not running in a transaction") + } + tx := &Tx{config: m.config} + tx.init() + return tx, nil +} + +// SetID sets the value of the id field. Note that this +// operation is only accepted on creation of LoginHandoff entities. +func (m *LoginHandoffMutation) SetID(id uuid.UUID) { + m.id = &id +} + +// ID returns the ID value in the mutation. Note that the ID is only available +// if it was provided to the builder or after it was returned from the database. +func (m *LoginHandoffMutation) ID() (id uuid.UUID, exists bool) { + if m.id == nil { + return + } + return *m.id, true +} + +// IDs queries the database and returns the entity ids that match the mutation's predicate. +// That means, if the mutation is applied within a transaction with an isolation level such +// as sql.LevelSerializable, the returned ids match the ids of the rows that will be updated +// or updated by the mutation. +func (m *LoginHandoffMutation) IDs(ctx context.Context) ([]uuid.UUID, error) { + switch { + case m.op.Is(OpUpdateOne | OpDeleteOne): + id, exists := m.ID() + if exists { + return []uuid.UUID{id}, nil + } + fallthrough + case m.op.Is(OpUpdate | OpDelete): + return m.Client().LoginHandoff.Query().Where(m.predicates...).IDs(ctx) + default: + return nil, fmt.Errorf("IDs is not allowed on %s operations", m.op) + } +} + +// SetCodeHash sets the "code_hash" field. +func (m *LoginHandoffMutation) SetCodeHash(s string) { + m.code_hash = &s +} + +// CodeHash returns the value of the "code_hash" field in the mutation. +func (m *LoginHandoffMutation) CodeHash() (r string, exists bool) { + v := m.code_hash + if v == nil { + return + } + return *v, true +} + +// OldCodeHash returns the old "code_hash" field's value of the LoginHandoff entity. +// If the LoginHandoff object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *LoginHandoffMutation) OldCodeHash(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCodeHash is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCodeHash requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCodeHash: %w", err) + } + return oldValue.CodeHash, nil +} + +// ResetCodeHash resets all changes to the "code_hash" field. +func (m *LoginHandoffMutation) ResetCodeHash() { + m.code_hash = nil +} + +// SetUserID sets the "user_id" field. +func (m *LoginHandoffMutation) SetUserID(u uuid.UUID) { + m.user = &u +} + +// UserID returns the value of the "user_id" field in the mutation. +func (m *LoginHandoffMutation) UserID() (r uuid.UUID, exists bool) { + v := m.user + if v == nil { + return + } + return *v, true +} + +// OldUserID returns the old "user_id" field's value of the LoginHandoff entity. +// If the LoginHandoff object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *LoginHandoffMutation) OldUserID(ctx context.Context) (v uuid.UUID, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUserID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUserID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUserID: %w", err) + } + return oldValue.UserID, nil +} + +// ResetUserID resets all changes to the "user_id" field. +func (m *LoginHandoffMutation) ResetUserID() { + m.user = nil +} + +// SetTokenTTLSeconds sets the "token_ttl_seconds" field. +func (m *LoginHandoffMutation) SetTokenTTLSeconds(i int64) { + m.token_ttl_seconds = &i + m.addtoken_ttl_seconds = nil +} + +// TokenTTLSeconds returns the value of the "token_ttl_seconds" field in the mutation. +func (m *LoginHandoffMutation) TokenTTLSeconds() (r int64, exists bool) { + v := m.token_ttl_seconds + if v == nil { + return + } + return *v, true +} + +// OldTokenTTLSeconds returns the old "token_ttl_seconds" field's value of the LoginHandoff entity. +// If the LoginHandoff object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *LoginHandoffMutation) OldTokenTTLSeconds(ctx context.Context) (v int64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldTokenTTLSeconds is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldTokenTTLSeconds requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldTokenTTLSeconds: %w", err) + } + return oldValue.TokenTTLSeconds, nil +} + +// AddTokenTTLSeconds adds i to the "token_ttl_seconds" field. +func (m *LoginHandoffMutation) AddTokenTTLSeconds(i int64) { + if m.addtoken_ttl_seconds != nil { + *m.addtoken_ttl_seconds += i + } else { + m.addtoken_ttl_seconds = &i + } +} + +// AddedTokenTTLSeconds returns the value that was added to the "token_ttl_seconds" field in this mutation. +func (m *LoginHandoffMutation) AddedTokenTTLSeconds() (r int64, exists bool) { + v := m.addtoken_ttl_seconds + if v == nil { + return + } + return *v, true +} + +// ResetTokenTTLSeconds resets all changes to the "token_ttl_seconds" field. +func (m *LoginHandoffMutation) ResetTokenTTLSeconds() { + m.token_ttl_seconds = nil + m.addtoken_ttl_seconds = nil +} + +// SetCodeChallenge sets the "code_challenge" field. +func (m *LoginHandoffMutation) SetCodeChallenge(s string) { + m.code_challenge = &s +} + +// CodeChallenge returns the value of the "code_challenge" field in the mutation. +func (m *LoginHandoffMutation) CodeChallenge() (r string, exists bool) { + v := m.code_challenge + if v == nil { + return + } + return *v, true +} + +// OldCodeChallenge returns the old "code_challenge" field's value of the LoginHandoff entity. +// If the LoginHandoff object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *LoginHandoffMutation) OldCodeChallenge(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCodeChallenge is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCodeChallenge requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCodeChallenge: %w", err) + } + return oldValue.CodeChallenge, nil +} + +// ResetCodeChallenge resets all changes to the "code_challenge" field. +func (m *LoginHandoffMutation) ResetCodeChallenge() { + m.code_challenge = nil +} + +// SetCallback sets the "callback" field. +func (m *LoginHandoffMutation) SetCallback(s string) { + m.callback = &s +} + +// Callback returns the value of the "callback" field in the mutation. +func (m *LoginHandoffMutation) Callback() (r string, exists bool) { + v := m.callback + if v == nil { + return + } + return *v, true +} + +// OldCallback returns the old "callback" field's value of the LoginHandoff entity. +// If the LoginHandoff object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *LoginHandoffMutation) OldCallback(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCallback is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCallback requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCallback: %w", err) + } + return oldValue.Callback, nil +} + +// ResetCallback resets all changes to the "callback" field. +func (m *LoginHandoffMutation) ResetCallback() { + m.callback = nil +} + +// SetExpiresAt sets the "expires_at" field. +func (m *LoginHandoffMutation) SetExpiresAt(t time.Time) { + m.expires_at = &t +} + +// ExpiresAt returns the value of the "expires_at" field in the mutation. +func (m *LoginHandoffMutation) ExpiresAt() (r time.Time, exists bool) { + v := m.expires_at + if v == nil { + return + } + return *v, true +} + +// OldExpiresAt returns the old "expires_at" field's value of the LoginHandoff entity. +// If the LoginHandoff object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *LoginHandoffMutation) OldExpiresAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldExpiresAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldExpiresAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldExpiresAt: %w", err) + } + return oldValue.ExpiresAt, nil +} + +// ResetExpiresAt resets all changes to the "expires_at" field. +func (m *LoginHandoffMutation) ResetExpiresAt() { + m.expires_at = nil +} + +// SetUsedAt sets the "used_at" field. +func (m *LoginHandoffMutation) SetUsedAt(t time.Time) { + m.used_at = &t +} + +// UsedAt returns the value of the "used_at" field in the mutation. +func (m *LoginHandoffMutation) UsedAt() (r time.Time, exists bool) { + v := m.used_at + if v == nil { + return + } + return *v, true +} + +// OldUsedAt returns the old "used_at" field's value of the LoginHandoff entity. +// If the LoginHandoff object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *LoginHandoffMutation) OldUsedAt(ctx context.Context) (v *time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUsedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUsedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUsedAt: %w", err) + } + return oldValue.UsedAt, nil +} + +// ClearUsedAt clears the value of the "used_at" field. +func (m *LoginHandoffMutation) ClearUsedAt() { + m.used_at = nil + m.clearedFields[loginhandoff.FieldUsedAt] = struct{}{} +} + +// UsedAtCleared returns if the "used_at" field was cleared in this mutation. +func (m *LoginHandoffMutation) UsedAtCleared() bool { + _, ok := m.clearedFields[loginhandoff.FieldUsedAt] + return ok +} + +// ResetUsedAt resets all changes to the "used_at" field. +func (m *LoginHandoffMutation) ResetUsedAt() { + m.used_at = nil + delete(m.clearedFields, loginhandoff.FieldUsedAt) +} + +// ClearUser clears the "user" edge to the User entity. +func (m *LoginHandoffMutation) ClearUser() { + m.cleareduser = true + m.clearedFields[loginhandoff.FieldUserID] = struct{}{} +} + +// UserCleared reports if the "user" edge to the User entity was cleared. +func (m *LoginHandoffMutation) UserCleared() bool { + return m.cleareduser +} + +// UserIDs returns the "user" edge IDs in the mutation. +// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use +// UserID instead. It exists only for internal usage by the builders. +func (m *LoginHandoffMutation) UserIDs() (ids []uuid.UUID) { + if id := m.user; id != nil { + ids = append(ids, *id) + } + return +} + +// ResetUser resets all changes to the "user" edge. +func (m *LoginHandoffMutation) ResetUser() { + m.user = nil + m.cleareduser = false +} + +// Where appends a list predicates to the LoginHandoffMutation builder. +func (m *LoginHandoffMutation) Where(ps ...predicate.LoginHandoff) { + m.predicates = append(m.predicates, ps...) +} + +// WhereP appends storage-level predicates to the LoginHandoffMutation builder. Using this method, +// users can use type-assertion to append predicates that do not depend on any generated package. +func (m *LoginHandoffMutation) WhereP(ps ...func(*sql.Selector)) { + p := make([]predicate.LoginHandoff, len(ps)) + for i := range ps { + p[i] = ps[i] + } + m.Where(p...) +} + +// Op returns the operation name. +func (m *LoginHandoffMutation) Op() Op { + return m.op +} + +// SetOp allows setting the mutation operation. +func (m *LoginHandoffMutation) SetOp(op Op) { + m.op = op +} + +// Type returns the node type of this mutation (LoginHandoff). +func (m *LoginHandoffMutation) Type() string { + return m.typ +} + +// Fields returns all fields that were changed during this mutation. Note that in +// order to get all numeric fields that were incremented/decremented, call +// AddedFields(). +func (m *LoginHandoffMutation) Fields() []string { + fields := make([]string, 0, 7) + if m.code_hash != nil { + fields = append(fields, loginhandoff.FieldCodeHash) + } + if m.user != nil { + fields = append(fields, loginhandoff.FieldUserID) + } + if m.token_ttl_seconds != nil { + fields = append(fields, loginhandoff.FieldTokenTTLSeconds) + } + if m.code_challenge != nil { + fields = append(fields, loginhandoff.FieldCodeChallenge) + } + if m.callback != nil { + fields = append(fields, loginhandoff.FieldCallback) + } + if m.expires_at != nil { + fields = append(fields, loginhandoff.FieldExpiresAt) + } + if m.used_at != nil { + fields = append(fields, loginhandoff.FieldUsedAt) + } + return fields +} + +// Field returns the value of a field with the given name. The second boolean +// return value indicates that this field was not set, or was not defined in the +// schema. +func (m *LoginHandoffMutation) Field(name string) (ent.Value, bool) { + switch name { + case loginhandoff.FieldCodeHash: + return m.CodeHash() + case loginhandoff.FieldUserID: + return m.UserID() + case loginhandoff.FieldTokenTTLSeconds: + return m.TokenTTLSeconds() + case loginhandoff.FieldCodeChallenge: + return m.CodeChallenge() + case loginhandoff.FieldCallback: + return m.Callback() + case loginhandoff.FieldExpiresAt: + return m.ExpiresAt() + case loginhandoff.FieldUsedAt: + return m.UsedAt() + } + return nil, false +} + +// OldField returns the old value of the field from the database. An error is +// returned if the mutation operation is not UpdateOne, or the query to the +// database failed. +func (m *LoginHandoffMutation) OldField(ctx context.Context, name string) (ent.Value, error) { + switch name { + case loginhandoff.FieldCodeHash: + return m.OldCodeHash(ctx) + case loginhandoff.FieldUserID: + return m.OldUserID(ctx) + case loginhandoff.FieldTokenTTLSeconds: + return m.OldTokenTTLSeconds(ctx) + case loginhandoff.FieldCodeChallenge: + return m.OldCodeChallenge(ctx) + case loginhandoff.FieldCallback: + return m.OldCallback(ctx) + case loginhandoff.FieldExpiresAt: + return m.OldExpiresAt(ctx) + case loginhandoff.FieldUsedAt: + return m.OldUsedAt(ctx) + } + return nil, fmt.Errorf("unknown LoginHandoff field %s", name) +} + +// SetField sets the value of a field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *LoginHandoffMutation) SetField(name string, value ent.Value) error { + switch name { + case loginhandoff.FieldCodeHash: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCodeHash(v) + return nil + case loginhandoff.FieldUserID: + v, ok := value.(uuid.UUID) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUserID(v) + return nil + case loginhandoff.FieldTokenTTLSeconds: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetTokenTTLSeconds(v) + return nil + case loginhandoff.FieldCodeChallenge: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCodeChallenge(v) + return nil + case loginhandoff.FieldCallback: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCallback(v) + return nil + case loginhandoff.FieldExpiresAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetExpiresAt(v) + return nil + case loginhandoff.FieldUsedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUsedAt(v) + return nil + } + return fmt.Errorf("unknown LoginHandoff field %s", name) +} + +// AddedFields returns all numeric fields that were incremented/decremented during +// this mutation. +func (m *LoginHandoffMutation) AddedFields() []string { + var fields []string + if m.addtoken_ttl_seconds != nil { + fields = append(fields, loginhandoff.FieldTokenTTLSeconds) + } + return fields +} + +// AddedField returns the numeric value that was incremented/decremented on a field +// with the given name. The second boolean return value indicates that this field +// was not set, or was not defined in the schema. +func (m *LoginHandoffMutation) AddedField(name string) (ent.Value, bool) { + switch name { + case loginhandoff.FieldTokenTTLSeconds: + return m.AddedTokenTTLSeconds() + } + return nil, false +} + +// AddField adds the value to the field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *LoginHandoffMutation) AddField(name string, value ent.Value) error { + switch name { + case loginhandoff.FieldTokenTTLSeconds: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddTokenTTLSeconds(v) + return nil + } + return fmt.Errorf("unknown LoginHandoff numeric field %s", name) +} + +// ClearedFields returns all nullable fields that were cleared during this +// mutation. +func (m *LoginHandoffMutation) ClearedFields() []string { + var fields []string + if m.FieldCleared(loginhandoff.FieldUsedAt) { + fields = append(fields, loginhandoff.FieldUsedAt) + } + return fields +} + +// FieldCleared returns a boolean indicating if a field with the given name was +// cleared in this mutation. +func (m *LoginHandoffMutation) FieldCleared(name string) bool { + _, ok := m.clearedFields[name] + return ok +} + +// ClearField clears the value of the field with the given name. It returns an +// error if the field is not defined in the schema. +func (m *LoginHandoffMutation) ClearField(name string) error { + switch name { + case loginhandoff.FieldUsedAt: + m.ClearUsedAt() + return nil + } + return fmt.Errorf("unknown LoginHandoff nullable field %s", name) +} + +// ResetField resets all changes in the mutation for the field with the given name. +// It returns an error if the field is not defined in the schema. +func (m *LoginHandoffMutation) ResetField(name string) error { + switch name { + case loginhandoff.FieldCodeHash: + m.ResetCodeHash() + return nil + case loginhandoff.FieldUserID: + m.ResetUserID() + return nil + case loginhandoff.FieldTokenTTLSeconds: + m.ResetTokenTTLSeconds() + return nil + case loginhandoff.FieldCodeChallenge: + m.ResetCodeChallenge() + return nil + case loginhandoff.FieldCallback: + m.ResetCallback() + return nil + case loginhandoff.FieldExpiresAt: + m.ResetExpiresAt() + return nil + case loginhandoff.FieldUsedAt: + m.ResetUsedAt() + return nil + } + return fmt.Errorf("unknown LoginHandoff field %s", name) +} + +// AddedEdges returns all edge names that were set/added in this mutation. +func (m *LoginHandoffMutation) AddedEdges() []string { + edges := make([]string, 0, 1) + if m.user != nil { + edges = append(edges, loginhandoff.EdgeUser) + } + return edges +} + +// AddedIDs returns all IDs (to other nodes) that were added for the given edge +// name in this mutation. +func (m *LoginHandoffMutation) AddedIDs(name string) []ent.Value { + switch name { + case loginhandoff.EdgeUser: + if id := m.user; id != nil { + return []ent.Value{*id} + } + } + return nil +} + +// RemovedEdges returns all edge names that were removed in this mutation. +func (m *LoginHandoffMutation) RemovedEdges() []string { + edges := make([]string, 0, 1) + return edges +} + +// RemovedIDs returns all IDs (to other nodes) that were removed for the edge with +// the given name in this mutation. +func (m *LoginHandoffMutation) RemovedIDs(name string) []ent.Value { + return nil +} + +// ClearedEdges returns all edge names that were cleared in this mutation. +func (m *LoginHandoffMutation) ClearedEdges() []string { + edges := make([]string, 0, 1) + if m.cleareduser { + edges = append(edges, loginhandoff.EdgeUser) + } + return edges +} + +// EdgeCleared returns a boolean which indicates if the edge with the given name +// was cleared in this mutation. +func (m *LoginHandoffMutation) EdgeCleared(name string) bool { + switch name { + case loginhandoff.EdgeUser: + return m.cleareduser + } + return false +} + +// ClearEdge clears the value of the edge with the given name. It returns an error +// if that edge is not defined in the schema. +func (m *LoginHandoffMutation) ClearEdge(name string) error { + switch name { + case loginhandoff.EdgeUser: + m.ClearUser() + return nil + } + return fmt.Errorf("unknown LoginHandoff unique edge %s", name) +} + +// ResetEdge resets all changes to the edge with the given name in this mutation. +// It returns an error if the edge is not defined in the schema. +func (m *LoginHandoffMutation) ResetEdge(name string) error { + switch name { + case loginhandoff.EdgeUser: + m.ResetUser() + return nil + } + return fmt.Errorf("unknown LoginHandoff edge %s", name) +} + // MembershipMutation represents an operation that mutates the Membership nodes in the graph. type MembershipMutation struct { config diff --git a/app/controlplane/pkg/data/ent/predicate/predicate.go b/app/controlplane/pkg/data/ent/predicate/predicate.go index 4545014e7..a4ba7fab4 100644 --- a/app/controlplane/pkg/data/ent/predicate/predicate.go +++ b/app/controlplane/pkg/data/ent/predicate/predicate.go @@ -30,6 +30,9 @@ type Integration func(*sql.Selector) // IntegrationAttachment is the predicate function for integrationattachment builders. type IntegrationAttachment func(*sql.Selector) +// LoginHandoff is the predicate function for loginhandoff builders. +type LoginHandoff func(*sql.Selector) + // Membership is the predicate function for membership builders. type Membership func(*sql.Selector) diff --git a/app/controlplane/pkg/data/ent/runtime.go b/app/controlplane/pkg/data/ent/runtime.go index c0614b24a..482787fb4 100644 --- a/app/controlplane/pkg/data/ent/runtime.go +++ b/app/controlplane/pkg/data/ent/runtime.go @@ -13,6 +13,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/groupmembership" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/integration" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/integrationattachment" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/loginhandoff" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/membership" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/organization" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/orginvitation" @@ -171,6 +172,12 @@ func init() { integrationattachmentDescID := integrationattachmentFields[0].Descriptor() // integrationattachment.DefaultID holds the default value on creation for the id field. integrationattachment.DefaultID = integrationattachmentDescID.Default.(func() uuid.UUID) + loginhandoffFields := schema.LoginHandoff{}.Fields() + _ = loginhandoffFields + // loginhandoffDescID is the schema descriptor for id field. + loginhandoffDescID := loginhandoffFields[0].Descriptor() + // loginhandoff.DefaultID holds the default value on creation for the id field. + loginhandoff.DefaultID = loginhandoffDescID.Default.(func() uuid.UUID) membershipFields := schema.Membership{}.Fields() _ = membershipFields // membershipDescCurrent is the schema descriptor for current field. diff --git a/app/controlplane/pkg/data/ent/schema/loginhandoff.go b/app/controlplane/pkg/data/ent/schema/loginhandoff.go new file mode 100644 index 000000000..e9350432d --- /dev/null +++ b/app/controlplane/pkg/data/ent/schema/loginhandoff.go @@ -0,0 +1,59 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// 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 schema + +import ( + "entgo.io/ent" + "entgo.io/ent/dialect/entsql" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" + "github.com/google/uuid" +) + +// LoginHandoff is a short-lived, single-use code that a login delivers to the client callback +// instead of the session token. It holds no token, only the hash of the code. +type LoginHandoff struct { + ent.Schema +} + +func (LoginHandoff) Fields() []ent.Field { + return []ent.Field{ + field.UUID("id", uuid.UUID{}).Default(uuid.New).Unique(), + // SHA-256 of the handoff code, hex encoded + field.String("code_hash").Immutable().Unique(), + field.UUID("user_id", uuid.UUID{}).Immutable(), + // Lifetime of the session token created at exchange time + field.Int64("token_ttl_seconds").Immutable(), + // PKCE S256 challenge sent when the login started + field.String("code_challenge").Immutable(), + field.String("callback").Immutable(), + field.Time("expires_at").Immutable(), + field.Time("used_at").Optional().Nillable(), + } +} + +func (LoginHandoff) Edges() []ent.Edge { + return []ent.Edge{ + edge.To("user", User.Type).Field("user_id").Unique().Required().Immutable().Annotations(entsql.Annotation{OnDelete: entsql.Cascade}), + } +} + +func (LoginHandoff) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("expires_at"), + } +} diff --git a/app/controlplane/pkg/data/ent/tx.go b/app/controlplane/pkg/data/ent/tx.go index 21663ad8b..f6c16a2e4 100644 --- a/app/controlplane/pkg/data/ent/tx.go +++ b/app/controlplane/pkg/data/ent/tx.go @@ -28,6 +28,8 @@ type Tx struct { Integration *IntegrationClient // IntegrationAttachment is the client for interacting with the IntegrationAttachment builders. IntegrationAttachment *IntegrationAttachmentClient + // LoginHandoff is the client for interacting with the LoginHandoff builders. + LoginHandoff *LoginHandoffClient // Membership is the client for interacting with the Membership builders. Membership *MembershipClient // OrgInvitation is the client for interacting with the OrgInvitation builders. @@ -191,6 +193,7 @@ func (tx *Tx) init() { tx.GroupMembership = NewGroupMembershipClient(tx.config) tx.Integration = NewIntegrationClient(tx.config) tx.IntegrationAttachment = NewIntegrationAttachmentClient(tx.config) + tx.LoginHandoff = NewLoginHandoffClient(tx.config) tx.Membership = NewMembershipClient(tx.config) tx.OrgInvitation = NewOrgInvitationClient(tx.config) tx.Organization = NewOrganizationClient(tx.config) diff --git a/app/controlplane/pkg/data/loginhandoff.go b/app/controlplane/pkg/data/loginhandoff.go new file mode 100644 index 000000000..692214b81 --- /dev/null +++ b/app/controlplane/pkg/data/loginhandoff.go @@ -0,0 +1,97 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// 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 data + +import ( + "context" + "fmt" + "time" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/loginhandoff" + "github.com/chainloop-dev/chainloop/pkg/otelx" + "github.com/go-kratos/kratos/v2/log" +) + +var loginHandoffRepoTracer = otelx.Tracer("chainloop-controlplane", "data/loginhandoff") + +type LoginHandoffRepo struct { + data *Data + log *log.Helper +} + +func NewLoginHandoffRepo(data *Data, logger log.Logger) biz.LoginHandoffRepo { + return &LoginHandoffRepo{ + data: data, + log: log.NewHelper(logger), + } +} + +func (r *LoginHandoffRepo) Create(ctx context.Context, codeHash string, h *biz.LoginHandoff) error { + ctx, span := otelx.Start(ctx, loginHandoffRepoTracer, "LoginHandoffRepo.Create") + defer span.End() + + return r.data.DB.LoginHandoff.Create(). + SetCodeHash(codeHash). + SetUserID(h.UserID). + SetTokenTTLSeconds(int64(h.TokenTTL / time.Second)). + SetCodeChallenge(h.CodeChallenge). + SetCallback(h.Callback). + SetExpiresAt(h.ExpiresAt). + Exec(ctx) +} + +func (r *LoginHandoffRepo) Consume(ctx context.Context, codeHash string, at time.Time) (*biz.LoginHandoff, bool, error) { + ctx, span := otelx.Start(ctx, loginHandoffRepoTracer, "LoginHandoffRepo.Consume") + defer span.End() + + // A single conditional update, so only one concurrent request can mark the code used + n, err := r.data.DB.LoginHandoff.Update(). + Where(loginhandoff.CodeHash(codeHash), loginhandoff.UsedAtIsNil()). + SetUsedAt(at). + Save(ctx) + if err != nil { + return nil, false, fmt.Errorf("marking login handoff used: %w", err) + } + + h, err := r.data.DB.LoginHandoff.Query().Where(loginhandoff.CodeHash(codeHash)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, false, biz.NewErrNotFound("login handoff") + } + return nil, false, fmt.Errorf("finding login handoff: %w", err) + } + + return entLoginHandoffToBiz(h), n == 1, nil +} + +func (r *LoginHandoffRepo) DeleteExpired(ctx context.Context, before time.Time) (int, error) { + ctx, span := otelx.Start(ctx, loginHandoffRepoTracer, "LoginHandoffRepo.DeleteExpired") + defer span.End() + + return r.data.DB.LoginHandoff.Delete().Where(loginhandoff.ExpiresAtLT(before)).Exec(ctx) +} + +func entLoginHandoffToBiz(h *ent.LoginHandoff) *biz.LoginHandoff { + return &biz.LoginHandoff{ + UserID: h.UserID, + TokenTTL: time.Duration(h.TokenTTLSeconds) * time.Second, + CodeChallenge: h.CodeChallenge, + Callback: h.Callback, + ExpiresAt: h.ExpiresAt, + } +} diff --git a/deployment/chainloop/Chart.yaml b/deployment/chainloop/Chart.yaml index 0f7729efe..523242319 100644 --- a/deployment/chainloop/Chart.yaml +++ b/deployment/chainloop/Chart.yaml @@ -7,7 +7,7 @@ description: Chainloop is an open source software supply chain control plane, a type: application # Bump the patch (not minor, not major) version on each change in the Chart Source code -version: 1.466.0 +version: 1.466.1 # Do not update appVersion, this is handled automatically by the release process appVersion: v1.123.0 diff --git a/deployment/chainloop/README.md b/deployment/chainloop/README.md index ccd8ddc37..ce50fd1ed 100644 --- a/deployment/chainloop/README.md +++ b/deployment/chainloop/README.md @@ -619,6 +619,7 @@ Once done, you can access with [two predefined users](https://github.com/chainlo | `controlplane.auth.allowList.rules` | List of domains or emails to allow | | | `controlplane.auth.allowList.selectedRoutes` | List of selected routes to allow. If not set it applies to all routes | | | `controlplane.auth.allowList.customMessage` | Custom message to display when a user is not allowed | | +| `controlplane.auth.allowLegacyLoginCallback` | Whether a login from a client that does not use the login code exchange still receives the session token in the callback URL. Allowed when not set. Set it to false to require the code exchange | | ### Control Plane Networking diff --git a/deployment/chainloop/templates/controlplane/configmap.yaml b/deployment/chainloop/templates/controlplane/configmap.yaml index 162d955ae..ea64c80e5 100644 --- a/deployment/chainloop/templates/controlplane/configmap.yaml +++ b/deployment/chainloop/templates/controlplane/configmap.yaml @@ -19,6 +19,11 @@ data: allow_list: {{- toYaml .Values.controlplane.auth.allowList | nindent 8 }} {{- end }} + {{- if hasKey .Values.controlplane.auth "allowLegacyLoginCallback" }} + login.yaml: | + auth: + allow_legacy_login_callback: {{ .Values.controlplane.auth.allowLegacyLoginCallback }} + {{- end }} config.yaml: | enable_profiler: {{ .Values.controlplane.enableProfiler }} server: diff --git a/deployment/chainloop/values.yaml b/deployment/chainloop/values.yaml index 9e09ca5c0..54f23126c 100644 --- a/deployment/chainloop/values.yaml +++ b/deployment/chainloop/values.yaml @@ -303,6 +303,9 @@ controlplane: # selectedRoutes: [] # customMessage: "" + ## @extra controlplane.auth.allowLegacyLoginCallback Whether a login from a client that does not use the login code exchange still receives the session token in the callback URL. Allowed when not set. Set it to false to require the code exchange + # allowLegacyLoginCallback: true + ## @section Control Plane Networking service: ## @param controlplane.service.type Service type diff --git a/docs/specs/issue-3626-login-code-exchange.md b/docs/specs/issue-3626-login-code-exchange.md index 41bdee9ca..03c5302a4 100644 --- a/docs/specs/issue-3626-login-code-exchange.md +++ b/docs/specs/issue-3626-login-code-exchange.md @@ -145,12 +145,14 @@ PKCE-style challenge (S256) from the client, plus the callback URL. - Require the OIDC state cookie at exchange: the CLI and a cross-origin web app do not have it. - Source: drafting -### D-003: Exchange endpoint -Unauthenticated RPC on the auth service with an HTTP POST mapping. -- Why: the CLI already has a gRPC connection, and other clients use HTTP. -- Alternatives considered: - - Plain HTTP handler next to the login and callback handlers: the CLI would need a second client. -- Source: drafting +### ~~D-003: Exchange endpoint~~ +Superseded by D-013. + +~~Unauthenticated RPC on the auth service with an HTTP POST mapping.~~ +- ~~Why: the CLI already has a gRPC connection, and other clients use HTTP.~~ +- ~~Alternatives considered:~~ + - ~~Plain HTTP handler next to the login and callback handlers: the CLI would need a second client.~~ +- ~~Source: drafting~~ ### D-004: Code store Database table with the code hash, the user, the expiry, and an atomic consume. No token at rest: the control plane creates the token at exchange. @@ -215,6 +217,13 @@ Same error to the client for every failure. The specific reason goes to the serv - Uniform error with no server reason: hard to debug. - Source: drafting +### D-013: Exchange endpoint is gRPC only +Supersedes D-003. Unauthenticated RPC on the auth service, with no HTTP mapping. +- Why: the CLI uses gRPC and the web UI uses gRPC-web, so no client needs a plain HTTP route. The endpoint is unauthenticated, and no HTTP route keeps that public surface smaller and avoids a cross-origin preflight from the web UI. +- Alternatives considered: + - Keep the HTTP mapping of D-003 for a future non-gRPC client: no such client exists today. A mapping can be added when one does. +- Source: PR #3630 review + ## Open Questions None. diff --git a/internal/oauth/constants.go b/internal/oauth/constants.go index be941d4f4..03da7e5b2 100644 --- a/internal/oauth/constants.go +++ b/internal/oauth/constants.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -21,4 +21,15 @@ const ( QueryParamCallback = "callback" QueryParamLongLived = "long-lived" QueryParamAuth0Connection = "connection" + // PKCE-style challenge that opts a login into the handoff code exchange + QueryParamCodeChallenge = "code_challenge" + QueryParamCodeChallengeMethod = "code_challenge_method" + // Callback parameter that carries the handoff code + QueryParamHandoff = "handoff" + // Callback parameter that carries the session token in the legacy flow + QueryParamLegacyToken = "t" + // Post-login redirect inside a callback URL + QueryParamReturnTo = "returnTo" + + CodeChallengeMethodS256 = "S256" ) diff --git a/internal/oauth/pkce.go b/internal/oauth/pkce.go new file mode 100644 index 000000000..876d78603 --- /dev/null +++ b/internal/oauth/pkce.go @@ -0,0 +1,45 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// 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 oauth + +import ( + "crypto/subtle" + "regexp" + + "golang.org/x/oauth2" +) + +// RFC 7636: a verifier is 43 to 128 unreserved characters. A S256 challenge is the +// unpadded base64url encoding of a SHA-256 digest, so always 43 characters. +// Verifiers and challenges are created with oauth2.GenerateVerifier and oauth2.S256ChallengeFromVerifier +var ( + codeVerifierRegexp = regexp.MustCompile(`^[A-Za-z0-9\-._~]{43,128}$`) + s256ChallengeRegexp = regexp.MustCompile(`^[A-Za-z0-9\-_]{43}$`) +) + +// ValidS256Challenge tells if the value has the shape of a S256 challenge +func ValidS256Challenge(challenge string) bool { + return s256ChallengeRegexp.MatchString(challenge) +} + +// VerifyS256 tells, in constant time, if the verifier matches the S256 challenge +func VerifyS256(verifier, challenge string) bool { + if !codeVerifierRegexp.MatchString(verifier) { + return false + } + + return subtle.ConstantTimeCompare([]byte(oauth2.S256ChallengeFromVerifier(verifier)), []byte(challenge)) == 1 +} diff --git a/internal/oauth/pkce_test.go b/internal/oauth/pkce_test.go new file mode 100644 index 000000000..b3b12d953 --- /dev/null +++ b/internal/oauth/pkce_test.go @@ -0,0 +1,75 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// 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 oauth + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/oauth2" +) + +func TestVerifyS256(t *testing.T) { + verifier := oauth2.GenerateVerifier() + other := oauth2.GenerateVerifier() + challenge := oauth2.S256ChallengeFromVerifier(verifier) + long := strings.Repeat("a", 129) + + testCases := []struct { + name string + verifier string + challenge string + want bool + }{ + {"matching verifier", verifier, challenge, true}, + {"other verifier", other, challenge, false}, + {"empty verifier", "", oauth2.S256ChallengeFromVerifier(""), false}, + {"verifier too short", "abc", oauth2.S256ChallengeFromVerifier("abc"), false}, + {"verifier too long", long, oauth2.S256ChallengeFromVerifier(long), false}, + {"verifier with invalid characters", verifier[:42] + "/", oauth2.S256ChallengeFromVerifier(verifier[:42] + "/"), false}, + {"plain challenge", verifier, verifier, false}, + {"empty challenge", verifier, "", false}, + // RFC 7636 appendix B + {"rfc example", "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, VerifyS256(tc.verifier, tc.challenge)) + }) + } +} + +func TestValidS256Challenge(t *testing.T) { + testCases := []struct { + challenge string + want bool + }{ + {"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", true}, + {oauth2.S256ChallengeFromVerifier(oauth2.GenerateVerifier()), true}, + {"", false}, + {"short", false}, + {"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM=", false}, + {"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw+cM", false}, + } + + for _, tc := range testCases { + t.Run(tc.challenge, func(t *testing.T) { + assert.Equal(t, tc.want, ValidS256Challenge(tc.challenge)) + }) + } +}