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[:])
+}