diff --git a/api/client/client.go b/api/client/client.go index 686d2abfbb817..3147a40ff8666 100644 --- a/api/client/client.go +++ b/api/client/client.go @@ -6013,3 +6013,18 @@ func (c *Client) DeleteAppAuthConfig(ctx context.Context, name string) error { }) return trace.Wrap(err) } + +// AppAuthConfigSessionsClient returns an [appauthconfigv1.AppAuthConfigSessionsServiceClient]. +func (c *Client) AppAuthConfigSessionsClient() appauthconfigv1.AppAuthConfigSessionsServiceClient { + return appauthconfigv1.NewAppAuthConfigSessionsServiceClient(c.conn) +} + +// CreateAppSessionWithJWT creates an app session using JWT token. +func (c *Client) CreateAppSessionWithJWT(ctx context.Context, req *appauthconfigv1.CreateAppSessionWithJWTRequest) (types.WebSession, error) { + clt := c.AppAuthConfigSessionsClient() + res, err := clt.CreateAppSessionWithJWT(ctx, req) + if err != nil { + return nil, trace.Wrap(err) + } + return res.GetSession(), nil +} diff --git a/api/gen/proto/go/teleport/appauthconfig/v1/appauthconfig_sessions_service.pb.go b/api/gen/proto/go/teleport/appauthconfig/v1/appauthconfig_sessions_service.pb.go new file mode 100644 index 0000000000000..a1c6375894459 --- /dev/null +++ b/api/gen/proto/go/teleport/appauthconfig/v1/appauthconfig_sessions_service.pb.go @@ -0,0 +1,310 @@ +// Copyright 2025 Gravitational, Inc. +// +// 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. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc (unknown) +// source: teleport/appauthconfig/v1/appauthconfig_sessions_service.proto + +package appauthconfigv1 + +import ( + types "github.com/gravitational/teleport/api/types" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// App contains information about the application the new session will access. +type App struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Cluster is cluster within which the application is running. + ClusterName string `protobuf:"bytes,1,opt,name=cluster_name,json=clusterName,proto3" json:"cluster_name,omitempty"` + // AppName is the name of the application. + AppName string `protobuf:"bytes,2,opt,name=app_name,json=appName,proto3" json:"app_name,omitempty"` + // Uri is the URI of the app. This is the internal endpoint where the + // application is running and isn't user-facing. + Uri string `protobuf:"bytes,3,opt,name=uri,proto3" json:"uri,omitempty"` + // PublicAddr is the application public address. This value must come from + // the application resource without modificiation. It will be used when + // creating the session and issuing the credentials. + PublicAddr string `protobuf:"bytes,4,opt,name=public_addr,json=publicAddr,proto3" json:"public_addr,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *App) Reset() { + *x = App{} + mi := &file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *App) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*App) ProtoMessage() {} + +func (x *App) ProtoReflect() protoreflect.Message { + mi := &file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_msgTypes[0] + 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 App.ProtoReflect.Descriptor instead. +func (*App) Descriptor() ([]byte, []int) { + return file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_rawDescGZIP(), []int{0} +} + +func (x *App) GetClusterName() string { + if x != nil { + return x.ClusterName + } + return "" +} + +func (x *App) GetAppName() string { + if x != nil { + return x.AppName + } + return "" +} + +func (x *App) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *App) GetPublicAddr() string { + if x != nil { + return x.PublicAddr + } + return "" +} + +// Request for CreateAppSessionWithJWT. +type CreateAppSessionWithJWTRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // ConfigName is the app auth config name used to create the app session. + ConfigName string `protobuf:"bytes,1,opt,name=config_name,json=configName,proto3" json:"config_name,omitempty"` + // App is application the session will access. + App *App `protobuf:"bytes,2,opt,name=app,proto3" json:"app,omitempty"` + // The JWT token used to create the app session. + Jwt string `protobuf:"bytes,3,opt,name=jwt,proto3" json:"jwt,omitempty"` + // RemoteAddr is a client (user's) address. + RemoteAddr string `protobuf:"bytes,4,opt,name=remote_addr,json=remoteAddr,proto3" json:"remote_addr,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateAppSessionWithJWTRequest) Reset() { + *x = CreateAppSessionWithJWTRequest{} + mi := &file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateAppSessionWithJWTRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateAppSessionWithJWTRequest) ProtoMessage() {} + +func (x *CreateAppSessionWithJWTRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_msgTypes[1] + 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 CreateAppSessionWithJWTRequest.ProtoReflect.Descriptor instead. +func (*CreateAppSessionWithJWTRequest) Descriptor() ([]byte, []int) { + return file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_rawDescGZIP(), []int{1} +} + +func (x *CreateAppSessionWithJWTRequest) GetConfigName() string { + if x != nil { + return x.ConfigName + } + return "" +} + +func (x *CreateAppSessionWithJWTRequest) GetApp() *App { + if x != nil { + return x.App + } + return nil +} + +func (x *CreateAppSessionWithJWTRequest) GetJwt() string { + if x != nil { + return x.Jwt + } + return "" +} + +func (x *CreateAppSessionWithJWTRequest) GetRemoteAddr() string { + if x != nil { + return x.RemoteAddr + } + return "" +} + +// Response for CreateAppSessionWithJWT. +type CreateAppSessionWithJWTResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Session is the app session. + Session *types.WebSessionV2 `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateAppSessionWithJWTResponse) Reset() { + *x = CreateAppSessionWithJWTResponse{} + mi := &file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateAppSessionWithJWTResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateAppSessionWithJWTResponse) ProtoMessage() {} + +func (x *CreateAppSessionWithJWTResponse) ProtoReflect() protoreflect.Message { + mi := &file_teleport_appauthconfig_v1_appauthconfig_sessions_service_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 CreateAppSessionWithJWTResponse.ProtoReflect.Descriptor instead. +func (*CreateAppSessionWithJWTResponse) Descriptor() ([]byte, []int) { + return file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_rawDescGZIP(), []int{2} +} + +func (x *CreateAppSessionWithJWTResponse) GetSession() *types.WebSessionV2 { + if x != nil { + return x.Session + } + return nil +} + +var File_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto protoreflect.FileDescriptor + +const file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_rawDesc = "" + + "\n" + + ">teleport/appauthconfig/v1/appauthconfig_sessions_service.proto\x12\x19teleport.appauthconfig.v1\x1a!teleport/legacy/types/types.proto\"v\n" + + "\x03App\x12!\n" + + "\fcluster_name\x18\x01 \x01(\tR\vclusterName\x12\x19\n" + + "\bapp_name\x18\x02 \x01(\tR\aappName\x12\x10\n" + + "\x03uri\x18\x03 \x01(\tR\x03uri\x12\x1f\n" + + "\vpublic_addr\x18\x04 \x01(\tR\n" + + "publicAddr\"\xa6\x01\n" + + "\x1eCreateAppSessionWithJWTRequest\x12\x1f\n" + + "\vconfig_name\x18\x01 \x01(\tR\n" + + "configName\x120\n" + + "\x03app\x18\x02 \x01(\v2\x1e.teleport.appauthconfig.v1.AppR\x03app\x12\x10\n" + + "\x03jwt\x18\x03 \x01(\tR\x03jwt\x12\x1f\n" + + "\vremote_addr\x18\x04 \x01(\tR\n" + + "remoteAddr\"P\n" + + "\x1fCreateAppSessionWithJWTResponse\x12-\n" + + "\asession\x18\x01 \x01(\v2\x13.types.WebSessionV2R\asession2\xb1\x01\n" + + "\x1cAppAuthConfigSessionsService\x12\x90\x01\n" + + "\x17CreateAppSessionWithJWT\x129.teleport.appauthconfig.v1.CreateAppSessionWithJWTRequest\x1a:.teleport.appauthconfig.v1.CreateAppSessionWithJWTResponseB^Z\\github.com/gravitational/teleport/api/gen/proto/go/teleport/appauthconfig/v1;appauthconfigv1b\x06proto3" + +var ( + file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_rawDescOnce sync.Once + file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_rawDescData []byte +) + +func file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_rawDescGZIP() []byte { + file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_rawDescOnce.Do(func() { + file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_rawDesc), len(file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_rawDesc))) + }) + return file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_rawDescData +} + +var file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_goTypes = []any{ + (*App)(nil), // 0: teleport.appauthconfig.v1.App + (*CreateAppSessionWithJWTRequest)(nil), // 1: teleport.appauthconfig.v1.CreateAppSessionWithJWTRequest + (*CreateAppSessionWithJWTResponse)(nil), // 2: teleport.appauthconfig.v1.CreateAppSessionWithJWTResponse + (*types.WebSessionV2)(nil), // 3: types.WebSessionV2 +} +var file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_depIdxs = []int32{ + 0, // 0: teleport.appauthconfig.v1.CreateAppSessionWithJWTRequest.app:type_name -> teleport.appauthconfig.v1.App + 3, // 1: teleport.appauthconfig.v1.CreateAppSessionWithJWTResponse.session:type_name -> types.WebSessionV2 + 1, // 2: teleport.appauthconfig.v1.AppAuthConfigSessionsService.CreateAppSessionWithJWT:input_type -> teleport.appauthconfig.v1.CreateAppSessionWithJWTRequest + 2, // 3: teleport.appauthconfig.v1.AppAuthConfigSessionsService.CreateAppSessionWithJWT:output_type -> teleport.appauthconfig.v1.CreateAppSessionWithJWTResponse + 3, // [3:4] is the sub-list for method output_type + 2, // [2:3] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_init() } +func file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_init() { + if File_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_rawDesc), len(file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_rawDesc)), + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_goTypes, + DependencyIndexes: file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_depIdxs, + MessageInfos: file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_msgTypes, + }.Build() + File_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto = out.File + file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_goTypes = nil + file_teleport_appauthconfig_v1_appauthconfig_sessions_service_proto_depIdxs = nil +} diff --git a/api/gen/proto/go/teleport/appauthconfig/v1/appauthconfig_sessions_service_grpc.pb.go b/api/gen/proto/go/teleport/appauthconfig/v1/appauthconfig_sessions_service_grpc.pb.go new file mode 100644 index 0000000000000..1a45ff008cb79 --- /dev/null +++ b/api/gen/proto/go/teleport/appauthconfig/v1/appauthconfig_sessions_service_grpc.pb.go @@ -0,0 +1,144 @@ +// Copyright 2025 Gravitational, Inc. +// +// 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. + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc (unknown) +// source: teleport/appauthconfig/v1/appauthconfig_sessions_service.proto + +package appauthconfigv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + AppAuthConfigSessionsService_CreateAppSessionWithJWT_FullMethodName = "/teleport.appauthconfig.v1.AppAuthConfigSessionsService/CreateAppSessionWithJWT" +) + +// AppAuthConfigSessionsServiceClient is the client API for AppAuthConfigSessionsService service. +// +// 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. +// +// AppAuthConfigSessionsService provides functions for managing sessions with +// app auth configs. +type AppAuthConfigSessionsServiceClient interface { + // CreateAppSessionWithJWT creates an app session using JWT token. + CreateAppSessionWithJWT(ctx context.Context, in *CreateAppSessionWithJWTRequest, opts ...grpc.CallOption) (*CreateAppSessionWithJWTResponse, error) +} + +type appAuthConfigSessionsServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewAppAuthConfigSessionsServiceClient(cc grpc.ClientConnInterface) AppAuthConfigSessionsServiceClient { + return &appAuthConfigSessionsServiceClient{cc} +} + +func (c *appAuthConfigSessionsServiceClient) CreateAppSessionWithJWT(ctx context.Context, in *CreateAppSessionWithJWTRequest, opts ...grpc.CallOption) (*CreateAppSessionWithJWTResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateAppSessionWithJWTResponse) + err := c.cc.Invoke(ctx, AppAuthConfigSessionsService_CreateAppSessionWithJWT_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// AppAuthConfigSessionsServiceServer is the server API for AppAuthConfigSessionsService service. +// All implementations must embed UnimplementedAppAuthConfigSessionsServiceServer +// for forward compatibility. +// +// AppAuthConfigSessionsService provides functions for managing sessions with +// app auth configs. +type AppAuthConfigSessionsServiceServer interface { + // CreateAppSessionWithJWT creates an app session using JWT token. + CreateAppSessionWithJWT(context.Context, *CreateAppSessionWithJWTRequest) (*CreateAppSessionWithJWTResponse, error) + mustEmbedUnimplementedAppAuthConfigSessionsServiceServer() +} + +// UnimplementedAppAuthConfigSessionsServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedAppAuthConfigSessionsServiceServer struct{} + +func (UnimplementedAppAuthConfigSessionsServiceServer) CreateAppSessionWithJWT(context.Context, *CreateAppSessionWithJWTRequest) (*CreateAppSessionWithJWTResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CreateAppSessionWithJWT not implemented") +} +func (UnimplementedAppAuthConfigSessionsServiceServer) mustEmbedUnimplementedAppAuthConfigSessionsServiceServer() { +} +func (UnimplementedAppAuthConfigSessionsServiceServer) testEmbeddedByValue() {} + +// UnsafeAppAuthConfigSessionsServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to AppAuthConfigSessionsServiceServer will +// result in compilation errors. +type UnsafeAppAuthConfigSessionsServiceServer interface { + mustEmbedUnimplementedAppAuthConfigSessionsServiceServer() +} + +func RegisterAppAuthConfigSessionsServiceServer(s grpc.ServiceRegistrar, srv AppAuthConfigSessionsServiceServer) { + // If the following call pancis, it indicates UnimplementedAppAuthConfigSessionsServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&AppAuthConfigSessionsService_ServiceDesc, srv) +} + +func _AppAuthConfigSessionsService_CreateAppSessionWithJWT_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateAppSessionWithJWTRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AppAuthConfigSessionsServiceServer).CreateAppSessionWithJWT(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AppAuthConfigSessionsService_CreateAppSessionWithJWT_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AppAuthConfigSessionsServiceServer).CreateAppSessionWithJWT(ctx, req.(*CreateAppSessionWithJWTRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// AppAuthConfigSessionsService_ServiceDesc is the grpc.ServiceDesc for AppAuthConfigSessionsService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var AppAuthConfigSessionsService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "teleport.appauthconfig.v1.AppAuthConfigSessionsService", + HandlerType: (*AppAuthConfigSessionsServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "CreateAppSessionWithJWT", + Handler: _AppAuthConfigSessionsService_CreateAppSessionWithJWT_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "teleport/appauthconfig/v1/appauthconfig_sessions_service.proto", +} diff --git a/api/proto/teleport/appauthconfig/v1/appauthconfig_sessions_service.proto b/api/proto/teleport/appauthconfig/v1/appauthconfig_sessions_service.proto new file mode 100644 index 0000000000000..d8ca318c8991c --- /dev/null +++ b/api/proto/teleport/appauthconfig/v1/appauthconfig_sessions_service.proto @@ -0,0 +1,61 @@ +// Copyright 2025 Gravitational, Inc. +// +// 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. + +syntax = "proto3"; + +package teleport.appauthconfig.v1; + +import "teleport/legacy/types/types.proto"; + +option go_package = "github.com/gravitational/teleport/api/gen/proto/go/teleport/appauthconfig/v1;appauthconfigv1"; + +// AppAuthConfigSessionsService provides functions for managing sessions with +// app auth configs. +service AppAuthConfigSessionsService { + // CreateAppSessionWithJWT creates an app session using JWT token. + rpc CreateAppSessionWithJWT(CreateAppSessionWithJWTRequest) returns (CreateAppSessionWithJWTResponse); +} + +// App contains information about the application the new session will access. +message App { + // Cluster is cluster within which the application is running. + string cluster_name = 1; + // AppName is the name of the application. + string app_name = 2; + // Uri is the URI of the app. This is the internal endpoint where the + // application is running and isn't user-facing. + string uri = 3; + // PublicAddr is the application public address. This value must come from + // the application resource without modificiation. It will be used when + // creating the session and issuing the credentials. + string public_addr = 4; +} + +// Request for CreateAppSessionWithJWT. +message CreateAppSessionWithJWTRequest { + // ConfigName is the app auth config name used to create the app session. + string config_name = 1; + // App is application the session will access. + App app = 2; + // The JWT token used to create the app session. + string jwt = 3; + // RemoteAddr is a client (user's) address. + string remote_addr = 4; +} + +// Response for CreateAppSessionWithJWT. +message CreateAppSessionWithJWTResponse { + // Session is the app session. + types.WebSessionV2 session = 1; +} diff --git a/lib/auth/appauthconfig/appauthconfigv1/events.go b/lib/auth/appauthconfig/appauthconfigv1/events.go index d3615b2a70496..ee47f9f685f57 100644 --- a/lib/auth/appauthconfig/appauthconfigv1/events.go +++ b/lib/auth/appauthconfig/appauthconfigv1/events.go @@ -66,3 +66,31 @@ func newDeleteAuditEvent(ctx context.Context, deletedName string) apievents.Audi }, } } + +func newVerifyJWTAuditEvent(ctx context.Context, req *appauthconfigv1.CreateAppSessionWithJWTRequest, sid string, err error) apievents.AuditEvent { + evt := &apievents.AppAuthConfigVerify{ + Metadata: apievents.Metadata{ + Code: events.AppAuthConfigVerifySuccessCode, + Type: events.AppAuthConfigVerifySuccessEvent, + }, + AppAuthConfig: req.ConfigName, + UserMetadata: authz.ClientUserMetadata(ctx), + SessionMetadata: apievents.SessionMetadata{SessionID: sid}, + AppMetadata: apievents.AppMetadata{ + AppName: req.App.AppName, + AppURI: req.App.Uri, + }, + Status: apievents.Status{ + Success: true, + }, + } + + if err != nil { + evt.Metadata.Code = events.AppAuthConfigVerifyFailureCode + evt.Metadata.Type = events.AppAuthConfigVerifyFailureEvent + evt.Status.Success = false + evt.Status.Error = err.Error() + } + + return evt +} diff --git a/lib/auth/appauthconfig/appauthconfigv1/sessions_service.go b/lib/auth/appauthconfig/appauthconfigv1/sessions_service.go new file mode 100644 index 0000000000000..32fe6b920adf9 --- /dev/null +++ b/lib/auth/appauthconfig/appauthconfigv1/sessions_service.go @@ -0,0 +1,323 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package appauthconfigv1 + +import ( + "cmp" + "context" + "encoding/json" + "log/slog" + "net/http" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + "github.com/gravitational/trace" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + + "github.com/gravitational/teleport" + appauthconfigv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/appauthconfig/v1" + "github.com/gravitational/teleport/api/types" + apievents "github.com/gravitational/teleport/api/types/events" + "github.com/gravitational/teleport/lib/authz" + "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/utils" +) + +const ( + // jwtMaxIssuedAtAfter is the amount of time that Teleport will still accept + // a JWT token after it was issued. + jwtMaxIssuedAtAfter = 30 * time.Minute + // defaultUsernameClaim is the default JWT claim used as username. + defaultUsernameClaim = "email" +) + +// CreateAppSessionForAppAuthRequest defines the request params for `CreateAppSessionForAppAuth`. +type CreateAppSessionForAppAuthRequest struct { + // ClusterName is cluster within which the application is running. + ClusterName string + // Username is the identity of the user requesting the session. + Username string + // LoginIP is an observed IP of the client, it will be embedded into certificates. + LoginIP string + // Roles optionally lists additional user roles + Roles []string + // Traits optionally lists role traits + Traits map[string][]string + // TTL is the session validity period. + TTL time.Duration + // SuggestedSessionID is the session ID suggested by the requester. + SuggestedSessionID string + // AppName is the name of the app. + AppName string + // AppURI is the URI of the app. This is the internal endpoint where the application is running and isn't user-facing. + AppURI string + // AppPublicAddr is the application public address. + AppPublicAddr string +} + +// AppSessionCreator creates new app sessions. +type AppSessionCreator interface { + // CreateAppSessionForAppAuth creates a new session for app auth requests. + CreateAppSessionForAppAuth(ctx context.Context, req *CreateAppSessionForAppAuthRequest) (types.WebSession, error) +} + +// Service implements the teleport.appauthconfig.v1.AppAuthConfigSessionsServiceServer +// gRPC API. +type SessionsService struct { + appauthconfigv1.UnimplementedAppAuthConfigSessionsServiceServer + + authorizer authz.Authorizer + cache services.AppAuthConfigReader + emitter apievents.Emitter + sessions AppSessionCreator + httpClient *http.Client + logger *slog.Logger + userGetter services.UserOrLoginStateGetter +} + +// SessionsServiceConfig holds configuration options for [SessionsService]. +type SessionsServiceConfig struct { + // Authorizer used by the service. + Authorizer authz.Authorizer + // Reader is the cache used to store app auth config resources. + Reader services.AppAuthConfigReader + // Emitter is an audit event emitter. + Emitter apievents.Emitter + // Logger is the slog logger. + Logger *slog.Logger + // SessionCreator is used to create app sessions. + SessionsCreator AppSessionCreator + // UserGetter is used to retrieve the user. + UserGetter services.UserOrLoginStateGetter + // HTTPClient is a http client used to make external HTTP requests. + HTTPClient *http.Client +} + +// NewService creates a new instance of [SessionsService]. +func NewSessionsService(cfg SessionsServiceConfig) (*SessionsService, error) { + switch { + case cfg.Authorizer == nil: + return nil, trace.BadParameter("authorizer is required for app auth config sessions service") + case cfg.Reader == nil: + return nil, trace.BadParameter("cache is required for app auth config sessions service") + case cfg.Emitter == nil: + return nil, trace.BadParameter("emitter is required for app auth config sessions service") + case cfg.HTTPClient == nil: + transport, err := defaults.Transport() + if err != nil { + return nil, trace.Wrap(err) + } + cfg.HTTPClient = &http.Client{ + Transport: otelhttp.NewTransport(transport), + Timeout: defaults.HTTPRequestTimeout, + } + case cfg.UserGetter == nil: + return nil, trace.BadParameter("user getter is required for app auth config sessions service") + } + + return &SessionsService{ + authorizer: cfg.Authorizer, + cache: cfg.Reader, + emitter: cfg.Emitter, + sessions: cfg.SessionsCreator, + httpClient: cfg.HTTPClient, + userGetter: cfg.UserGetter, + logger: cmp.Or(cfg.Logger, slog.Default()), + }, nil +} + +// CreateAppSessionWithJwt implements appauthconfigv1.AppAuthConfigSessionsServiceServer. +func (s *SessionsService) CreateAppSessionWithJWT(ctx context.Context, req *appauthconfigv1.CreateAppSessionWithJWTRequest) (_ *appauthconfigv1.CreateAppSessionWithJWTResponse, err error) { + defer func() { + if emitErr := s.emitter.EmitAuditEvent(ctx, newVerifyJWTAuditEvent(ctx, req, "", err)); emitErr != nil { + s.logger.ErrorContext(ctx, "failed to emit jwt verification audit event", "error", emitErr) + } + }() + + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + if !authz.HasBuiltinRole(*authCtx, string(types.RoleProxy)) { + return nil, trace.AccessDenied("this request can be only executed by a proxy") + } + + sid := services.GenerateAppSessionIDFromJWT(req.Jwt) + if err := validateCreateAppSessionWithJWTRequest(req); err != nil { + return nil, trace.Wrap(err) + } + + config, err := s.cache.GetAppAuthConfig(ctx, req.ConfigName) + if err != nil { + return nil, trace.Wrap(err) + } + + jwtConfig := config.Spec.GetJwt() + if jwtConfig == nil { + return nil, trace.BadParameter("app auth config jwt session can only start with jwt configs") + } + + jwks, sigs, err := retrieveJWKSAppAuthConfig(jwtConfig, s.httpClient) + if err != nil { + return nil, trace.Wrap(err) + } + + username, tokenTTL, err := verifyAppAuthJWTToken(req.Jwt, jwks, sigs, jwtConfig) + if err != nil { + return nil, trace.Wrap(err) + } + + user, err := services.GetUserOrLoginState(ctx, s.userGetter, username) + if err != nil { + return nil, trace.Wrap(err) + } + + ws, err := s.sessions.CreateAppSessionForAppAuth(ctx, &CreateAppSessionForAppAuthRequest{ + Username: username, + ClusterName: req.App.ClusterName, + AppName: req.App.AppName, + AppURI: req.App.Uri, + AppPublicAddr: req.App.PublicAddr, + LoginIP: req.RemoteAddr, + Roles: user.GetRoles(), + Traits: user.GetTraits(), + TTL: time.Until(tokenTTL), + SuggestedSessionID: sid, + }) + if err != nil { + s.logger.WarnContext(ctx, "failed to create a web session from jwt token", "error", err) + return nil, trace.Wrap(err) + } + + return &appauthconfigv1.CreateAppSessionWithJWTResponse{Session: ws.(*types.WebSessionV2)}, nil +} + +// retrieveJWKSAppAuthConfig retrieves JWKS contents given a app auth JWT +// config. +func retrieveJWKSAppAuthConfig(jwtConfig *appauthconfigv1.AppAuthConfigJWTSpec, httpClient *http.Client) (*jose.JSONWebKeySet, []jose.SignatureAlgorithm, error) { + var rawJwks []byte + switch jwksSource := jwtConfig.KeysSource.(type) { + case *appauthconfigv1.AppAuthConfigJWTSpec_JwksUrl: + req, err := http.NewRequest(http.MethodGet, jwksSource.JwksUrl, nil) + if err != nil { + return nil, nil, trace.Wrap(err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, nil, trace.Wrap(err) + } + defer resp.Body.Close() + + rawJwks, err = utils.ReadAtMost(resp.Body, teleport.MaxHTTPRequestSize) + if err != nil { + return nil, nil, trace.Wrap(err) + } + case *appauthconfigv1.AppAuthConfigJWTSpec_StaticJwks: + rawJwks = []byte(jwksSource.StaticJwks) + } + + var jwks jose.JSONWebKeySet + if err := json.Unmarshal(rawJwks, &jwks); err != nil { + return nil, nil, trace.Wrap(err, "unable to parse JWKS contents") + } + + sigs := make([]jose.SignatureAlgorithm, len(jwks.Keys)) + for i, jwk := range jwks.Keys { + sigs[i] = jose.SignatureAlgorithm(jwk.Algorithm) + } + + if len(sigs) == 0 { + return nil, nil, trace.BadParameter("empty JWKS contents") + } + + return &jwks, sigs, nil +} + +// verifyAppAuthJWTToken verifies the provided JWT token using app auth config +// and returns the extracted username from JWT claims. +func verifyAppAuthJWTToken(jwtToken string, jwks *jose.JSONWebKeySet, sigs []jose.SignatureAlgorithm, jwtConfig *appauthconfigv1.AppAuthConfigJWTSpec) (string, time.Time, error) { + parsedJWT, err := jwt.ParseSigned(jwtToken, sigs) + if err != nil { + return "", time.Time{}, trace.Wrap(err) + } + + var ( + claims jwt.Claims + remainingClaims map[string]any + ) + if err := parsedJWT.Claims(jwks, &claims, &remainingClaims); err != nil { + return "", time.Time{}, trace.Wrap(err) + } + + expected := jwt.Expected{ + Issuer: jwtConfig.Issuer, + AnyAudience: jwt.Audience{jwtConfig.Audience}, + Time: time.Now(), + } + if err := claims.Validate(expected); err != nil { + return "", time.Time{}, trace.Wrap(err) + } + + if claims.IssuedAt == nil { + return "", time.Time{}, trace.BadParameter("token must have 'iat' claim") + } + + if claims.Expiry == nil { + return "", time.Time{}, trace.BadParameter("token must have 'exp' claim") + } + + issuedAt := claims.IssuedAt.Time() + if time.Since(issuedAt) > jwtMaxIssuedAtAfter { + return "", time.Time{}, trace.BadParameter("token must be issued recently to be used") + } + + usernameClaimName := cmp.Or(jwtConfig.UsernameClaim, defaultUsernameClaim) + usernameClaim, ok := remainingClaims[usernameClaimName] + if !ok { + return "", time.Time{}, trace.BadParameter("token must have %q claim", usernameClaimName) + } + + usernameClaimStr, ok := usernameClaim.(string) + if !ok { + return "", time.Time{}, trace.BadParameter("token username claim %q must be of string type", usernameClaimName) + } + + return usernameClaimStr, claims.Expiry.Time(), nil +} + +func validateCreateAppSessionWithJWTRequest(req *appauthconfigv1.CreateAppSessionWithJWTRequest) error { + switch { + case req.ConfigName == "": + return trace.BadParameter("create app session request requires an app auth config name") + case req.App == nil: + return trace.BadParameter("create app session request requires app information") + case req.App.AppName == "": + return trace.BadParameter("create app session request requires app name") + case req.App.ClusterName == "": + return trace.BadParameter("create app session request requires app cluster name") + case req.App.PublicAddr == "": + return trace.BadParameter("create app session request requires app public address") + case req.App.Uri == "": + return trace.BadParameter("create app session request requires app uri") + } + + return nil +} diff --git a/lib/auth/appauthconfig/appauthconfigv1/sessions_service_test.go b/lib/auth/appauthconfig/appauthconfigv1/sessions_service_test.go new file mode 100644 index 0000000000000..207938b28d394 --- /dev/null +++ b/lib/auth/appauthconfig/appauthconfigv1/sessions_service_test.go @@ -0,0 +1,578 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package appauthconfigv1 + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "testing/synctest" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/gravitational/trace" + "github.com/stretchr/testify/require" + + appauthconfigv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/appauthconfig/v1" + labelv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/label/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/appauthconfig" + apievents "github.com/gravitational/teleport/api/types/events" + "github.com/gravitational/teleport/api/types/userloginstate" + "github.com/gravitational/teleport/lib/authz" + "github.com/gravitational/teleport/lib/cryptosuites" + "github.com/gravitational/teleport/lib/events" + "github.com/gravitational/teleport/lib/events/eventstest" + "github.com/gravitational/teleport/lib/services" +) + +func TestRetrieveJWKS(t *testing.T) { + _, baseSigs, baseJwks := newJWTSigner(t) + encodedJwks, err := json.Marshal(baseJwks) + require.NoError(t, err) + + assertEqualJwks := func(expectedJwks *jose.JSONWebKeySet) require.ValueAssertionFunc { + return func(tt require.TestingT, i1 any, i2 ...any) { + require.Empty(t, cmp.Diff( + expectedJwks, + i1, + cmpopts.IgnoreFields( + jose.JSONWebKey{}, + "Key", + "Certificates", + "CertificateThumbprintSHA1", + "CertificateThumbprintSHA256", + ), + )) + } + } + + t.Run("static", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + jwksRes, sigsRes, err := retrieveJWKSAppAuthConfig( + &appauthconfigv1.AppAuthConfigJWTSpec{ + KeysSource: &appauthconfigv1.AppAuthConfigJWTSpec_StaticJwks{ + StaticJwks: string(encodedJwks), + }, + }, + nil, /* httpClient */ + ) + require.NoError(t, err) + assertEqualJwks(baseJwks)(t, jwksRes) + require.ElementsMatch(t, baseSigs, sigsRes) + }) + + t.Run("empty", func(t *testing.T) { + _, _, err := retrieveJWKSAppAuthConfig( + &appauthconfigv1.AppAuthConfigJWTSpec{ + KeysSource: &appauthconfigv1.AppAuthConfigJWTSpec_StaticJwks{ + StaticJwks: "{}", + }, + }, + nil, /* httpClient */ + ) + require.Error(t, err) + }) + }) + + t.Run("url", func(t *testing.T) { + for name, tc := range map[string]struct { + httpHandler http.HandlerFunc + assertError require.ErrorAssertionFunc + assertJwks require.ValueAssertionFunc + assertSigs require.ValueAssertionFunc + }{ + "success with valid JWKS": { + httpHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(encodedJwks) + }, + assertError: require.NoError, + assertJwks: assertEqualJwks(baseJwks), + assertSigs: func(tt require.TestingT, i1 any, i2 ...any) { + require.ElementsMatch(t, baseSigs, i1, i2...) + }, + }, + "success with empty JWKS": { + httpHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{}")) + }, + assertError: require.Error, + assertJwks: require.Nil, + assertSigs: require.Empty, + }, + "error": { + httpHandler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + assertError: require.Error, + assertJwks: require.Nil, + assertSigs: require.Empty, + }, + } { + t.Run(name, func(t *testing.T) { + httpSrv := httptest.NewServer(tc.httpHandler) + t.Cleanup(func() { httpSrv.Close() }) + + jwks, sigs, err := retrieveJWKSAppAuthConfig( + &appauthconfigv1.AppAuthConfigJWTSpec{ + KeysSource: &appauthconfigv1.AppAuthConfigJWTSpec_JwksUrl{ + JwksUrl: httpSrv.URL, + }, + }, + httpSrv.Client(), + ) + tc.assertError(t, err) + tc.assertJwks(t, jwks) + tc.assertSigs(t, sigs) + }) + } + }) +} + +func TestVerifyJWTToken(t *testing.T) { + signer, sigs, jwks := newJWTSigner(t) + + for name, tc := range map[string]struct { + config *appauthconfigv1.AppAuthConfigJWTSpec + claims jwt.Claims + usernameClaim any + assertError require.ErrorAssertionFunc + assertUsername require.ValueAssertionFunc + }{ + "valid token": { + config: &appauthconfigv1.AppAuthConfigJWTSpec{ + Issuer: "https://issuer-url/", + Audience: "teleport", + UsernameClaim: "username", + }, + claims: jwt.Claims{ + Issuer: "https://issuer-url/", + Audience: []string{"teleport"}, + IssuedAt: jwt.NewNumericDate(time.Now()), + Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + usernameClaim: struct { + Username string `json:"username"` + }{Username: "user@example.com"}, + assertError: require.NoError, + assertUsername: func(tt require.TestingT, i1 any, i2 ...any) { + require.Equal(tt, "user@example.com", i1, i2) + }, + }, + "default username claim": { + config: &appauthconfigv1.AppAuthConfigJWTSpec{ + Issuer: "https://issuer-url/", + Audience: "teleport", + }, + claims: jwt.Claims{ + Issuer: "https://issuer-url/", + Audience: []string{"teleport"}, + IssuedAt: jwt.NewNumericDate(time.Now()), + Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + usernameClaim: struct { + Email string `json:"email"` + }{Email: "user@example.com"}, + assertError: require.NoError, + assertUsername: func(tt require.TestingT, i1 any, i2 ...any) { + require.Equal(tt, "user@example.com", i1, i2) + }, + }, + "wrong audience": { + config: &appauthconfigv1.AppAuthConfigJWTSpec{ + Issuer: "https://issuer-url/", + Audience: "teleport", + }, + claims: jwt.Claims{ + Issuer: "https://issuer-url/", + Audience: []string{"random-app"}, + IssuedAt: jwt.NewNumericDate(time.Now()), + Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + usernameClaim: struct { + Email string `json:"email"` + }{Email: "user@example.com"}, + assertError: require.Error, + assertUsername: require.Empty, + }, + "wrong issuer": { + config: &appauthconfigv1.AppAuthConfigJWTSpec{ + Issuer: "https://issuer-url/", + Audience: "teleport", + }, + claims: jwt.Claims{ + Issuer: "https://random-issuer/", + Audience: []string{"teleport"}, + IssuedAt: jwt.NewNumericDate(time.Now()), + Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + usernameClaim: struct { + Email string `json:"email"` + }{Email: "user@example.com"}, + assertError: require.Error, + assertUsername: require.Empty, + }, + "missing username claim": { + config: &appauthconfigv1.AppAuthConfigJWTSpec{ + Issuer: "https://issuer-url/", + Audience: "teleport", + }, + claims: jwt.Claims{ + Issuer: "https://issuer-url/", + Audience: []string{"teleport"}, + IssuedAt: jwt.NewNumericDate(time.Now()), + Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + usernameClaim: struct{}{}, + assertError: require.Error, + assertUsername: require.Empty, + }, + "missing issued at claim": { + config: &appauthconfigv1.AppAuthConfigJWTSpec{ + Issuer: "https://issuer-url/", + Audience: "teleport", + }, + claims: jwt.Claims{ + Issuer: "https://issuer-url/", + Audience: []string{"teleport"}, + Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + usernameClaim: struct { + Email string `json:"email"` + }{Email: "user@example.com"}, + assertError: require.Error, + assertUsername: require.Empty, + }, + "missing exp claim": { + config: &appauthconfigv1.AppAuthConfigJWTSpec{ + Issuer: "https://issuer-url/", + Audience: "teleport", + }, + claims: jwt.Claims{ + Issuer: "https://issuer-url/", + Audience: []string{"teleport"}, + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + usernameClaim: struct { + Email string `json:"email"` + }{Email: "user@example.com"}, + assertError: require.Error, + assertUsername: require.Empty, + }, + } { + t.Run(name, func(t *testing.T) { + jwtToken, err := jwt.Signed(signer).Claims(tc.claims).Claims(tc.usernameClaim).Serialize() + require.NoError(t, err) + + username, _, err := verifyAppAuthJWTToken(jwtToken, jwks, sigs, tc.config) + tc.assertError(t, err) + tc.assertUsername(t, username) + }) + } + + t.Run("token freshness", func(t *testing.T) { + config := &appauthconfigv1.AppAuthConfigJWTSpec{ + Issuer: "https://issuer-url/", + Audience: "teleport", + UsernameClaim: "email", + } + baseClaims := jwt.Claims{ + Issuer: config.Issuer, + Audience: []string{config.Audience}, + } + usernameClaim := struct { + Email string `json:"email"` + }{Email: "user@example.com"} + + for name, tc := range map[string]struct { + passedTime time.Duration + assertError require.ErrorAssertionFunc + }{ + "fresh token succeeds": { + passedTime: 0, + assertError: require.NoError, + }, + "outside freshness range": { + passedTime: jwtMaxIssuedAtAfter + 1, + assertError: require.Error, + }, + } { + t.Run(name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + claims := baseClaims + issuedAt := time.Now() + claims.IssuedAt = jwt.NewNumericDate(issuedAt) + claims.Expiry = jwt.NewNumericDate(issuedAt.Add(tc.passedTime + time.Hour)) // Always generate non-expired token. + + jwtToken, err := jwt.Signed(signer).Claims(claims).Claims(usernameClaim).Serialize() + require.NoError(t, err) + + time.Sleep(tc.passedTime) + + _, _, err = verifyAppAuthJWTToken(jwtToken, jwks, sigs, config) + tc.assertError(t, err) + }) + }) + } + }) +} + +func TestCreateAppSessionWithJWT(t *testing.T) { + issuer := "https://external-idp/" + header := "Authorization" + audience := "teleport" + usernameClaim := "email" + + signer, _, baseJwks := newJWTSigner(t) + jwtToken := generateJWTToken(t, signer, issuer, audience) + encodedJwks, err := json.Marshal(baseJwks) + require.NoError(t, err) + + user, err := types.NewUser("user") + require.NoError(t, err) + + config := appauthconfig.NewAppAuthConfigJWT("test-config", []*labelv1.Label{{Name: "*", Values: []string{"*"}}}, &appauthconfigv1.AppAuthConfigJWTSpec{ + Issuer: issuer, + AuthorizationHeader: header, + Audience: audience, + UsernameClaim: usernameClaim, + KeysSource: &appauthconfigv1.AppAuthConfigJWTSpec_StaticJwks{ + StaticJwks: string(encodedJwks), + }, + }) + + assertAuditEventFailure := func(tt require.TestingT, i1 any, i2 ...any) { + require.IsType(t, &apievents.AppAuthConfigVerify{}, i1, i2...) + evt, _ := i1.(*apievents.AppAuthConfigVerify) + require.Equal(t, events.AppAuthConfigVerifyFailureCode, evt.Metadata.Code) + require.Equal(t, events.AppAuthConfigVerifyFailureEvent, evt.Metadata.Type) + require.False(t, evt.Status.Success) + require.NotEmpty(t, evt.Status.Error) + } + + for name, tc := range map[string]struct { + authorizer authz.AuthorizerFunc + userGetter *mockUserGetter + config *appauthconfigv1.AppAuthConfig + configErr error + createAppSessionErr error + token string + assertError require.ErrorAssertionFunc + assertAuditEvent require.ValueAssertionFunc + }{ + "create new session": { + config: config, + token: jwtToken, + authorizer: func(ctx context.Context) (*authz.Context, error) { + return &authz.Context{ + Identity: authz.BuiltinRole{Role: types.RoleProxy}, + Checker: &fakeAccessChecker{role: types.RoleProxy}, + }, nil + + }, + userGetter: &mockUserGetter{getStateErr: trace.NotFound(""), user: user}, + assertError: require.NoError, + assertAuditEvent: func(tt require.TestingT, i1 any, i2 ...any) { + require.IsType(t, &apievents.AppAuthConfigVerify{}, i1, i2...) + evt, _ := i1.(*apievents.AppAuthConfigVerify) + require.Equal(t, events.AppAuthConfigVerifySuccessCode, evt.Metadata.Code) + require.Equal(t, events.AppAuthConfigVerifySuccessEvent, evt.Metadata.Type) + require.True(t, evt.Status.Success) + }, + }, + "user not found": { + config: config, + token: jwtToken, + authorizer: func(ctx context.Context) (*authz.Context, error) { + return &authz.Context{ + Identity: authz.BuiltinRole{Role: types.RoleProxy}, + Checker: &fakeAccessChecker{role: types.RoleProxy}, + }, nil + + }, + userGetter: &mockUserGetter{getStateErr: trace.NotFound(""), getUserErr: trace.NotFound("")}, + assertError: require.Error, + assertAuditEvent: assertAuditEventFailure, + }, + "wrong role requesting": { + config: config, + token: jwtToken, + authorizer: func(ctx context.Context) (*authz.Context, error) { + return &authz.Context{ + Identity: authz.BuiltinRole{Role: types.RoleAuth}, + Checker: &fakeAccessChecker{role: types.RoleAuth}, + }, nil + }, + assertError: require.Error, + assertAuditEvent: assertAuditEventFailure, + }, + "invalid token": { + config: config, + token: "", + authorizer: func(ctx context.Context) (*authz.Context, error) { + return &authz.Context{ + Identity: authz.BuiltinRole{Role: types.RoleProxy}, + Checker: &fakeAccessChecker{role: types.RoleProxy}, + }, nil + }, + assertError: require.Error, + assertAuditEvent: assertAuditEventFailure, + }, + "error while retrieving config": { + configErr: trace.NotFound("config not found"), + token: jwtToken, + authorizer: func(ctx context.Context) (*authz.Context, error) { + return &authz.Context{ + Identity: authz.BuiltinRole{Role: types.RoleProxy}, + Checker: &fakeAccessChecker{role: types.RoleProxy}, + }, nil + }, + assertError: require.Error, + assertAuditEvent: assertAuditEventFailure, + }, + } { + t.Run(name, func(t *testing.T) { + emitter := &eventstest.MockRecorderEmitter{} + svc, err := NewSessionsService(SessionsServiceConfig{ + Emitter: emitter, + Reader: &mockAppAuthConfigReader{ + getConfig: tc.config, + getConfigErr: tc.configErr, + }, + SessionsCreator: &mockSessionsCreator{ + session: createAppSession(t), + sessionErr: tc.createAppSessionErr, + }, + Authorizer: tc.authorizer, + UserGetter: tc.userGetter, + }) + require.NoError(t, err) + + _, err = svc.CreateAppSessionWithJWT(t.Context(), &appauthconfigv1.CreateAppSessionWithJWTRequest{ + ConfigName: "app-config-example", + Jwt: tc.token, + App: &appauthconfigv1.App{ + AppName: "mcp-app", + PublicAddr: "https://proxy/mcp-app", + Uri: "mcp+https://localhost/mcp", + ClusterName: "example", + }, + }) + tc.assertError(t, err) + tc.assertAuditEvent(t, emitter.LastEvent()) + }) + } +} + +type mockAppAuthConfigReader struct { + services.AppAuthConfigReader + + getConfig *appauthconfigv1.AppAuthConfig + getConfigErr error +} + +func (m *mockAppAuthConfigReader) GetAppAuthConfig(_ context.Context, _ string) (*appauthconfigv1.AppAuthConfig, error) { + return m.getConfig, m.getConfigErr +} + +type mockSessionsCreator struct { + session types.WebSession + sessionErr error +} + +func (m *mockSessionsCreator) CreateAppSessionForAppAuth(ctx context.Context, req *CreateAppSessionForAppAuthRequest) (types.WebSession, error) { + return m.session, m.sessionErr +} + +type fakeAccessChecker struct { + services.AccessChecker + role types.SystemRole +} + +func (f fakeAccessChecker) HasRole(role string) bool { + return string(f.role) == role +} + +type mockUserGetter struct { + services.UserOrLoginStateGetter + + state *userloginstate.UserLoginState + getStateErr error + user types.User + getUserErr error +} + +func (m *mockUserGetter) GetUserLoginState(context.Context, string) (*userloginstate.UserLoginState, error) { + return m.state, m.getStateErr +} + +func (m *mockUserGetter) GetUser(context.Context, string, bool) (types.User, error) { + return m.user, m.getUserErr +} + +func newJWTSigner(t *testing.T) (jose.Signer, []jose.SignatureAlgorithm, *jose.JSONWebKeySet) { + t.Helper() + + kid := "kid-example" + privateKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.ECDSAP256) + require.NoError(t, err) + + signer, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.ES256, Key: privateKey}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", kid), + ) + require.NoError(t, err) + + jwks := &jose.JSONWebKeySet{Keys: []jose.JSONWebKey{ + {Algorithm: string(jose.ES256), KeyID: kid, Key: privateKey.Public()}, + }} + return signer, []jose.SignatureAlgorithm{jose.ES256}, jwks +} + +func generateJWTToken(t *testing.T, signer jose.Signer, issuer, audience string) string { + t.Helper() + + token, err := jwt.Signed(signer).Claims(jwt.Claims{ + Issuer: issuer, + Audience: jwt.Audience{audience}, + IssuedAt: jwt.NewNumericDate(time.Now()), + Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }).Claims( + struct { + Email string `json:"email"` + }{Email: "user@example.com"}, + ).Serialize() + require.NoError(t, err) + return token +} + +func createAppSession(t *testing.T) types.WebSession { + t.Helper() + appSession, err := types.NewWebSession(uuid.New().String(), types.KindAppSession, types.WebSessionSpecV2{ + User: "testuser", + }) + require.NoError(t, err) + return appSession +} diff --git a/lib/auth/authclient/clt.go b/lib/auth/authclient/clt.go index 38d23945541c8..3c3ce8d57ac80 100644 --- a/lib/auth/authclient/clt.go +++ b/lib/auth/authclient/clt.go @@ -1615,6 +1615,7 @@ type ClientI interface { services.VnetConfigGetter services.HealthCheckConfig services.AppAuthConfig + services.AppAuthConfigSessions types.Events services.ScopedAccessClientGetter diff --git a/lib/auth/grpcserver.go b/lib/auth/grpcserver.go index 4c4f9aa339b64..964cb063d7ddc 100644 --- a/lib/auth/grpcserver.go +++ b/lib/auth/grpcserver.go @@ -6558,6 +6558,18 @@ func NewGRPCServer(cfg GRPCServerConfig) (*GRPCServer, error) { } appauthconfigv1pb.RegisterAppAuthConfigServiceServer(server, appAuthConfigSvc) + appAuthConfigSessionsSvc, err := appauthconfigv1.NewSessionsService(appauthconfigv1.SessionsServiceConfig{ + Authorizer: cfg.Authorizer, + Reader: cfg.AuthServer.Cache, + Emitter: cfg.Emitter, + SessionsCreator: cfg.AuthServer, + UserGetter: cfg.AuthServer, + }) + if err != nil { + return nil, trace.Wrap(err) + } + appauthconfigv1pb.RegisterAppAuthConfigSessionsServiceServer(server, appAuthConfigSessionsSvc) + return authServer, nil } diff --git a/lib/auth/sessions.go b/lib/auth/sessions.go index a71d3a9f89156..49ed1e0317a03 100644 --- a/lib/auth/sessions.go +++ b/lib/auth/sessions.go @@ -38,6 +38,7 @@ import ( "github.com/gravitational/teleport/api/utils/keys" "github.com/gravitational/teleport/api/utils/keys/hardwarekey" "github.com/gravitational/teleport/entitlements" + "github.com/gravitational/teleport/lib/auth/appauthconfig/appauthconfigv1" "github.com/gravitational/teleport/lib/cryptosuites" "github.com/gravitational/teleport/lib/defaults" dtauthz "github.com/gravitational/teleport/lib/devicetrust/authz" @@ -447,6 +448,8 @@ type NewAppSessionRequest struct { Identity tlsca.Identity // ClientAddr is a client (user's) address. ClientAddr string + // SuggestedSessionID is a session ID suggested by the requester. + SuggestedSessionID string // BotName is the name of the bot that is creating this session. // Empty if not a bot. @@ -553,10 +556,13 @@ func (a *Server) CreateAppSessionFromReq(ctx context.Context, req NewAppSessionR return nil, trace.Wrap(err) } - // Create services.WebSession for this session. - sessionID, err := utils.CryptoRandomHex(defaults.SessionTokenBytes) - if err != nil { - return nil, trace.Wrap(err) + sessionID := req.SuggestedSessionID + if sessionID == "" { + // Create services.WebSession for this session. + sessionID, err = utils.CryptoRandomHex(defaults.SessionTokenBytes) + if err != nil { + return nil, trace.Wrap(err) + } } // Create certificate for this session. @@ -829,3 +835,36 @@ func (a *Server) CreateSnowflakeSession(ctx context.Context, req types.CreateSno return session, nil } + +// CreateAppSessionForAppAuth creates a new app session based on app auth +// config. +func (a *Server) CreateAppSessionForAppAuth(ctx context.Context, req *appauthconfigv1.CreateAppSessionForAppAuthRequest) (types.WebSession, error) { + if !modules.GetModules().Features().GetEntitlement(entitlements.App).Enabled { + return nil, trace.AccessDenied( + "this Teleport cluster is not licensed for application access, please contact the cluster administrator") + } + + sess, err := a.CreateAppSessionFromReq(ctx, NewAppSessionRequest{ + NewWebSessionRequest: NewWebSessionRequest{ + User: req.Username, + LoginIP: req.LoginIP, + SessionTTL: req.TTL, + Roles: req.Roles, + Traits: req.Traits, + // Always attest the web session as sessions from app auth will + // always come from proxy, and will only be visible/available to + // auth and proxy instances. + AttestWebSession: true, + }, + ClusterName: req.ClusterName, + AppName: req.AppName, + AppURI: req.AppURI, + PublicAddr: req.AppPublicAddr, + SuggestedSessionID: req.SuggestedSessionID, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + return sess, nil +} diff --git a/lib/services/appauthconfig.go b/lib/services/appauthconfig.go index 4298b339d09bc..6f15df2ae0490 100644 --- a/lib/services/appauthconfig.go +++ b/lib/services/appauthconfig.go @@ -20,6 +20,8 @@ package services import ( "context" + "crypto/sha256" + "encoding/hex" "github.com/gravitational/trace" @@ -50,6 +52,13 @@ type AppAuthConfig interface { DeleteAppAuthConfig(ctx context.Context, name string) error } +// AppAuthConfigSessions is a service that manages sessions using app auth +// config. +type AppAuthConfigSessions interface { + // CreateAppSessionWithJWT creates an app session using JWT token. + CreateAppSessionWithJWT(ctx context.Context, req *appauthconfigv1.CreateAppSessionWithJWTRequest) (types.WebSession, error) +} + // ValidateAppAuthConfig validates the given app auth config. func ValidateAppAuthConfig(s *appauthconfigv1.AppAuthConfig) error { switch { @@ -97,3 +106,9 @@ func validateJWTAppAuthConfig(s *appauthconfigv1.AppAuthConfigJWTSpec) error { return nil } + +// GenerateAppSessionIDFromJWT generates a app session id based on JWT token. +func GenerateAppSessionIDFromJWT(jwtToken string) string { + jwtHash := sha256.Sum256([]byte(jwtToken)) + return hex.EncodeToString(jwtHash[:]) +}