diff --git a/.github/workflows/analysis-scorecard.yaml b/.github/workflows/analysis-scorecard.yaml index d21c1093..b89e9d67 100644 --- a/.github/workflows/analysis-scorecard.yaml +++ b/.github/workflows/analysis-scorecard.yaml @@ -35,7 +35,7 @@ jobs: publish_results: true - name: Upload results as artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: OpenSSF Scorecard results path: results.sarif diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index d9e4fe1a..5dcc8c72 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -125,7 +125,7 @@ jobs: - name: Build and push image id: build - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x @@ -256,7 +256,7 @@ jobs: run: cat trivy-results.sarif - name: Upload Trivy scan results as artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: "[${{ github.job }}] Trivy scan results" path: trivy-results.sarif diff --git a/connector/microsoft/microsoft.go b/connector/microsoft/microsoft.go index ca6e025d..efe96faf 100644 --- a/connector/microsoft/microsoft.go +++ b/connector/microsoft/microsoft.go @@ -63,26 +63,32 @@ type Config struct { DomainHint string `json:"domainHint"` Scopes []string `json:"scopes"` // defaults to scopeUser (user.read) + + // PreferredUsernameField allows users to set the field to any of the + // following values: "name", "email", "mailNickname" or "onPremisesSamAccountName". + // If unset, the preferred_username field will remain empty. + PreferredUsernameField string `json:"preferredUsernameField"` } // Open returns a strategy for logging in through Microsoft. func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) { m := microsoftConnector{ - apiURL: strings.TrimSuffix(c.APIURL, "/"), - graphURL: strings.TrimSuffix(c.GraphURL, "/"), - redirectURI: c.RedirectURI, - clientID: c.ClientID, - clientSecret: c.ClientSecret, - tenant: c.Tenant, - onlySecurityGroups: c.OnlySecurityGroups, - groups: c.Groups, - groupNameFormat: c.GroupNameFormat, - useGroupsAsWhitelist: c.UseGroupsAsWhitelist, - logger: logger.With(slog.Group("connector", "type", "microsoft", "id", id)), - emailToLowercase: c.EmailToLowercase, - promptType: c.PromptType, - domainHint: c.DomainHint, - scopes: c.Scopes, + apiURL: strings.TrimSuffix(c.APIURL, "/"), + graphURL: strings.TrimSuffix(c.GraphURL, "/"), + redirectURI: c.RedirectURI, + clientID: c.ClientID, + clientSecret: c.ClientSecret, + tenant: c.Tenant, + onlySecurityGroups: c.OnlySecurityGroups, + groups: c.Groups, + groupNameFormat: c.GroupNameFormat, + useGroupsAsWhitelist: c.UseGroupsAsWhitelist, + logger: logger.With(slog.Group("connector", "type", "microsoft", "id", id)), + emailToLowercase: c.EmailToLowercase, + promptType: c.PromptType, + domainHint: c.DomainHint, + scopes: c.Scopes, + preferredUsernameField: c.PreferredUsernameField, } if m.apiURL == "" { @@ -123,21 +129,22 @@ var ( ) type microsoftConnector struct { - apiURL string - graphURL string - redirectURI string - clientID string - clientSecret string - tenant string - onlySecurityGroups bool - groupNameFormat GroupNameFormat - groups []string - useGroupsAsWhitelist bool - logger *slog.Logger - emailToLowercase bool - promptType string - domainHint string - scopes []string + apiURL string + graphURL string + redirectURI string + clientID string + clientSecret string + tenant string + onlySecurityGroups bool + groupNameFormat GroupNameFormat + groups []string + useGroupsAsWhitelist bool + logger *slog.Logger + emailToLowercase bool + promptType string + domainHint string + scopes []string + preferredUsernameField string } func (c *microsoftConnector) isOrgTenant() bool { @@ -223,6 +230,7 @@ func (c *microsoftConnector) HandleCallback(s connector.Scopes, connData []byte, Email: user.Email, EmailVerified: true, } + c.setPreferredUsername(&identity, user) if c.groupsRequired(s.Groups) { groups, err := c.getGroups(ctx, client, user.ID) @@ -314,6 +322,7 @@ func (c *microsoftConnector) Refresh(ctx context.Context, s connector.Scopes, id identity.Username = user.Name identity.Email = user.Email + c.setPreferredUsername(&identity, user) if c.groupsRequired(s.Groups) { groups, err := c.getGroups(ctx, client, user.ID) @@ -326,6 +335,23 @@ func (c *microsoftConnector) Refresh(ctx context.Context, s connector.Scopes, id return identity, nil } +func (c *microsoftConnector) setPreferredUsername(identity *connector.Identity, u user) { + switch c.preferredUsernameField { + case "name": + identity.PreferredUsername = u.Name + case "email": + identity.PreferredUsername = u.Email + case "mailNickname": + identity.PreferredUsername = u.MailNickname + case "onPremisesSamAccountName": + identity.PreferredUsername = u.OnPremisesSamAccountName + default: + if c.preferredUsernameField != "" { + c.logger.Warn("preferred_username left empty. Invalid microsoft field mapped to preferred_username", "field", c.preferredUsernameField) + } + } +} + // https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/resources/user // id - The unique identifier for the user. Inherited from // @@ -342,22 +368,37 @@ func (c *microsoftConnector) Refresh(ctx context.Context, s connector.Scopes, id // // The UPN is an Internet-style login name for the user // based on the Internet standard RFC 822. By convention, -// this should map to the user's email name. The general +// this should map to the user’s email name. The general // format is alias@domain, where domain must be present in // the tenant’s collection of verified domains. This // property is required when a user is created. The // verified domains for the tenant can be accessed from the // verifiedDomains property of organization. Supports // $filter and $orderby. +// +// mailNickname - The mail alias for the user. +// +// This property must be specified when a user is created. +// Maximum length is 64 characters. Supports $filter. +// +// onPremisesSamAccountName - Contains the on-premises SAM account name +// +// synchronized from the on-premises directory. +// This property is only populated for customers +// who are synchronizing their on-premises directory +// to Azure Active Directory via Azure AD Connect. +// Read-only. type user struct { - ID string `json:"id"` - Name string `json:"displayName"` - Email string `json:"userPrincipalName"` + ID string `json:"id"` + Name string `json:"displayName"` + Email string `json:"userPrincipalName"` + MailNickname string `json:"mailNickname"` + OnPremisesSamAccountName string `json:"onPremisesSamAccountName"` } func (c *microsoftConnector) user(ctx context.Context, client *http.Client) (u user, err error) { // https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_get - req, err := http.NewRequest("GET", c.graphURL+"/v1.0/me?$select=id,displayName,userPrincipalName", nil) + req, err := http.NewRequest("GET", c.graphURL+"/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName", nil) if err != nil { return u, fmt.Errorf("new req: %v", err) } diff --git a/connector/microsoft/microsoft_test.go b/connector/microsoft/microsoft_test.go index f0dcd96d..275d3a4f 100644 --- a/connector/microsoft/microsoft_test.go +++ b/connector/microsoft/microsoft_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "net/http" "net/http/httptest" "net/url" @@ -82,7 +83,7 @@ func TestLoginURLWithOptions(t *testing.T) { func TestUserIdentityFromGraphAPI(t *testing.T) { s := newTestServer(map[string]testResponse{ - "/v1.0/me?$select=id,displayName,userPrincipalName": { + "/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName": { data: user{ID: "S56767889", Name: "Jane Doe", Email: "jane.doe@example.com"}, }, "/" + tenant + "/oauth2/v2.0/token": dummyToken, @@ -102,9 +103,39 @@ func TestUserIdentityFromGraphAPI(t *testing.T) { expectEquals(t, len(identity.Groups), 0) } +func TestPreferredUsernameField(t *testing.T) { + s := newTestServer(map[string]testResponse{ + "/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName": { + data: user{ID: "S56767889", Name: "Jane Doe", Email: "jane.doe@example.com", MailNickname: "janedoe", OnPremisesSamAccountName: "DOMAIN\\janedoe"}, + }, + "/" + tenant + "/oauth2/v2.0/token": dummyToken, + }) + defer s.Close() + + tests := []struct { + field string + expected string + }{ + {"", ""}, + {"name", "Jane Doe"}, + {"email", "jane.doe@example.com"}, + {"mailNickname", "janedoe"}, + {"onPremisesSamAccountName", "DOMAIN\\janedoe"}, + {"invalidstring", ""}, + } + + for _, tt := range tests { + req, _ := http.NewRequest("GET", s.URL, nil) + c := microsoftConnector{apiURL: s.URL, graphURL: s.URL, tenant: tenant, preferredUsernameField: tt.field, logger: slog.Default()} + identity, err := c.HandleCallback(connector.Scopes{Groups: false}, nil, req) + expectNil(t, err) + expectEquals(t, identity.PreferredUsername, tt.expected) + } +} + func TestUserGroupsFromGraphAPI(t *testing.T) { s := newTestServer(map[string]testResponse{ - "/v1.0/me?$select=id,displayName,userPrincipalName": {data: user{}}, + "/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName": {data: user{}}, "/v1.0/me/getMemberGroups": {data: map[string]interface{}{ "value": []string{"a", "b"}, }}, @@ -122,7 +153,7 @@ func TestUserGroupsFromGraphAPI(t *testing.T) { func TestUserNotInRequiredGroupFromGraphAPI(t *testing.T) { s := newTestServer(map[string]testResponse{ - "/v1.0/me?$select=id,displayName,userPrincipalName": { + "/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName": { data: user{ID: "user-id-123", Name: "Jane Doe", Email: "jane.doe@example.com"}, }, // The user is a member of groups "c" and "d", but the connector only diff --git a/connector/oidc/oidc.go b/connector/oidc/oidc.go index 07e371dd..6063c42b 100644 --- a/connector/oidc/oidc.go +++ b/connector/oidc/oidc.go @@ -228,6 +228,7 @@ var brokenAuthHeaderDomains = []string{ // connectorData stores information for sessions authenticated by this connector type connectorData struct { RefreshToken []byte + IDToken []byte // raw upstream id_token JWT for RP-Initiated logout } // Detect auth header provider issues for known providers. This lets users @@ -736,6 +737,9 @@ func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.I cd := connectorData{ RefreshToken: []byte(token.RefreshToken), } + if rawIDToken, ok := token.Extra("id_token").(string); ok { + cd.IDToken = []byte(rawIDToken) + } connData, err := json.Marshal(&cd) if err != nil { @@ -766,7 +770,7 @@ func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.I // LogoutURL returns the upstream OIDC provider's end_session_endpoint URL. // Per the OIDC RP-Initiated Logout spec, the post_logout_redirect_uri parameter // tells the upstream where to redirect after logout. -func (c *oidcConnector) LogoutURL(_ context.Context, _ []byte, postLogoutRedirectURI string) (string, error) { +func (c *oidcConnector) LogoutURL(_ context.Context, rawConnectorData []byte, postLogoutRedirectURI string) (string, error) { if c.endSessionURL == "" { return "", nil } @@ -781,6 +785,16 @@ func (c *oidcConnector) LogoutURL(_ context.Context, _ []byte, postLogoutRedirec q.Set("post_logout_redirect_uri", postLogoutRedirectURI) q.Set("client_id", c.oauth2Config.ClientID) } + // Per the RP-Initiated Logout spec, id_token_hint is independently valid + // of post_logout_redirect_uri — include it whenever we have one. + if len(rawConnectorData) > 0 { + var cd connectorData + if err := json.Unmarshal(rawConnectorData, &cd); err == nil { + if len(cd.IDToken) > 0 { + q.Set("id_token_hint", string(cd.IDToken)) + } + } + } u.RawQuery = q.Encode() return u.String(), nil diff --git a/connector/oidc/oidc_test.go b/connector/oidc/oidc_test.go index 77473b0d..29ec09f0 100644 --- a/connector/oidc/oidc_test.go +++ b/connector/oidc/oidc_test.go @@ -979,10 +979,22 @@ func expectEquals(t *testing.T, a interface{}, b interface{}) { } func TestLogoutURL(t *testing.T) { + idTokenConnData, err := json.Marshal(connectorData{ + RefreshToken: []byte("refresh"), + IDToken: []byte("id-token-jwt"), + }) + require.NoError(t, err) + + noIDTokenConnData, err := json.Marshal(connectorData{ + RefreshToken: []byte("refresh"), + }) + require.NoError(t, err) + tests := []struct { name string endSessionURL string postLogoutRedirectURI string + connectorData []byte wantURL string wantEmpty bool }{ @@ -1008,6 +1020,33 @@ func TestLogoutURL(t *testing.T) { postLogoutRedirectURI: "https://dex.example.com/callback", wantURL: "https://provider.example.com/logout?client_id=clientID&existing=param&post_logout_redirect_uri=https%3A%2F%2Fdex.example.com%2Fcallback", }, + { + name: "with id_token_hint from connector data", + endSessionURL: "https://provider.example.com/logout", + postLogoutRedirectURI: "https://dex.example.com/logout/callback", + connectorData: idTokenConnData, + wantURL: "https://provider.example.com/logout?client_id=clientID&id_token_hint=id-token-jwt&post_logout_redirect_uri=https%3A%2F%2Fdex.example.com%2Flogout%2Fcallback", + }, + { + name: "id_token_hint included without post_logout_redirect_uri", + endSessionURL: "https://provider.example.com/logout", + connectorData: idTokenConnData, + wantURL: "https://provider.example.com/logout?id_token_hint=id-token-jwt", + }, + { + name: "connector data without IDToken omits id_token_hint", + endSessionURL: "https://provider.example.com/logout", + postLogoutRedirectURI: "https://dex.example.com/logout/callback", + connectorData: noIDTokenConnData, + wantURL: "https://provider.example.com/logout?client_id=clientID&post_logout_redirect_uri=https%3A%2F%2Fdex.example.com%2Flogout%2Fcallback", + }, + { + name: "malformed connector data is ignored", + endSessionURL: "https://provider.example.com/logout", + postLogoutRedirectURI: "https://dex.example.com/logout/callback", + connectorData: []byte("not-json"), + wantURL: "https://provider.example.com/logout?client_id=clientID&post_logout_redirect_uri=https%3A%2F%2Fdex.example.com%2Flogout%2Fcallback", + }, } for _, tc := range tests { @@ -1019,7 +1058,7 @@ func TestLogoutURL(t *testing.T) { }, } - got, err := conn.LogoutURL(context.Background(), nil, tc.postLogoutRedirectURI) + got, err := conn.LogoutURL(context.Background(), tc.connectorData, tc.postLogoutRedirectURI) require.NoError(t, err) if tc.wantEmpty { diff --git a/examples/go.mod b/examples/go.mod index bf51713b..148c8b69 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -3,7 +3,7 @@ module github.com/dexidp/dex/examples go 1.25.0 require ( - github.com/coreos/go-oidc/v3 v3.17.0 + github.com/coreos/go-oidc/v3 v3.18.0 github.com/dexidp/dex/api/v2 v2.4.0 github.com/spf13/cobra v1.10.2 golang.org/x/oauth2 v0.36.0 diff --git a/examples/go.sum b/examples/go.sum index 5b87a121..84bab46e 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -1,7 +1,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= -github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/dexidp/dex/api/v2 v2.4.0 h1:gNba7n6BKVp8X4Jp24cxYn5rIIGhM6kDOXcZoL6tr9A= github.com/dexidp/dex/api/v2 v2.4.0/go.mod h1:/p550ADvFFh7K95VmhUD+jgm15VdaNnab9td8DHOpyI= diff --git a/go.mod b/go.mod index a1ac5793..f57a0fb2 100644 --- a/go.mod +++ b/go.mod @@ -16,8 +16,8 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-sql-driver/mysql v1.9.3 - github.com/go-webauthn/webauthn v0.16.3 - github.com/google/cel-go v0.27.0 + github.com/go-webauthn/webauthn v0.16.4 + github.com/google/cel-go v0.28.0 github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 @@ -25,7 +25,7 @@ require ( github.com/kylelemons/godebug v1.1.0 github.com/lib/pq v1.12.3 github.com/mattermost/xml-roundtrip-validator v0.1.0 - github.com/mattn/go-sqlite3 v1.14.41 + github.com/mattn/go-sqlite3 v1.14.42 github.com/oklog/run v1.2.0 github.com/openbao/openbao/api/v2 v2.5.1 github.com/pkg/errors v0.9.1 @@ -36,9 +36,9 @@ require ( github.com/stretchr/testify v1.11.1 go.etcd.io/etcd/client/pkg/v3 v3.6.10 go.etcd.io/etcd/client/v3 v3.6.10 - golang.org/x/crypto v0.49.0 + golang.org/x/crypto v0.50.0 golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 - golang.org/x/net v0.52.0 + golang.org/x/net v0.53.0 golang.org/x/oauth2 v0.36.0 google.golang.org/api v0.275.0 google.golang.org/grpc v1.80.0 @@ -77,7 +77,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/inflect v0.19.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/go-webauthn/x v0.2.2 // indirect + github.com/go-webauthn/x v0.2.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -133,12 +133,12 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/mod v0.33.0 // indirect + golang.org/x/mod v0.34.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.42.0 // indirect + golang.org/x/tools v0.43.0 // indirect golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect diff --git a/go.sum b/go.sum index 9a34f37d..4fc06279 100644 --- a/go.sum +++ b/go.sum @@ -98,10 +98,10 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/go-webauthn/webauthn v0.16.3 h1:RorP0c6VbaKP0i0Jxf/vAf7EFb2lmdLW8GLKITeaN5A= -github.com/go-webauthn/webauthn v0.16.3/go.mod h1:R2xjJxSPat5PYKg5r6cUmqXgbHtbv4GmF6uGkqFMLNI= -github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk= -github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4= +github.com/go-webauthn/webauthn v0.16.4 h1:R9jqR/cYZa7hRquFF7Za/8qoH/K/TIs1/Q/4CyGN+1Q= +github.com/go-webauthn/webauthn v0.16.4/go.mod h1:SU2ljAgToTV/YLPI0C05QS4qn+e04WpB5g1RMfcZfS4= +github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA= +github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -109,8 +109,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= -github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= +github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= +github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= @@ -197,8 +197,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-sqlite3 v1.14.41 h1:8p7Pwz5NHkEbWSqc/ygU4CBGubhFFkpgP9KwcdkAHNA= -github.com/mattn/go-sqlite3 v1.14.41/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= +github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= @@ -311,20 +311,20 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -336,20 +336,20 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY= golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= diff --git a/server/handlers.go b/server/handlers.go index 32b2b5b1..d2412c98 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -449,8 +449,8 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { return } if redirectURL != "" { - // Session found but consent required — no UI allowed. - s.redirectWithError(w, r, authReq, errInteractionRequired, "Consent required") + // Session found but user interaction is needed (consent or MFA) — no UI allowed. + s.redirectWithError(w, r, authReq, errInteractionRequired, "User interaction required") return } return diff --git a/server/handlers_test.go b/server/handlers_test.go index 76c3c2e0..d2124412 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -908,6 +908,61 @@ func TestScopesCoveredByConsent(t *testing.T) { } } +// TestConsentSurvivesSessionDeletion verifies that UserIdentity.Consents +// persists independently from AuthSession lifecycle (logout should not +// clear consent decisions). +func TestConsentSurvivesSessionDeletion(t *testing.T) { + ctx := t.Context() + + httpServer, s := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + userID := "test-user" + connectorID := "mock" + clientID := "test-client" + + // Create UserIdentity with existing consents. + require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: userID, + ConnectorID: connectorID, + Claims: storage.Claims{UserID: userID, Username: "testuser"}, + Consents: map[string][]string{clientID: {"openid", "email", "profile"}}, + CreatedAt: time.Now(), + LastLogin: time.Now(), + })) + + // Create and then delete the session (simulating logout). + require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: userID, ConnectorID: connectorID, Nonce: "nonce", + CreatedAt: time.Now(), LastActivity: time.Now(), + })) + require.NoError(t, s.storage.DeleteAuthSession(ctx, userID, connectorID)) + + // Session is gone. + _, err := s.storage.GetAuthSession(ctx, userID, connectorID) + require.ErrorIs(t, err, storage.ErrNotFound) + + // Consent survives. + ui, err := s.storage.GetUserIdentity(ctx, userID, connectorID) + require.NoError(t, err) + require.Equal(t, []string{"openid", "email", "profile"}, ui.Consents[clientID], + "consent should survive session deletion") +} + +// TestConsentIsolatedBetweenClients verifies that consent given for +// client-A does not satisfy scope check for client-B. +func TestConsentIsolatedBetweenClients(t *testing.T) { + approvedForA := map[string][]string{"client-a": {"openid", "email"}} + + // client-b should not have consent. + require.False(t, scopesCoveredByConsent(approvedForA["client-b"], []string{"openid", "email"}), + "consent for client-a should not cover client-b") + + // client-a should have consent. + require.True(t, scopesCoveredByConsent(approvedForA["client-a"], []string{"openid", "email"}), + "consent for client-a should cover client-a's requested scopes") +} + func TestHandlePasswordLoginWithSkipApproval(t *testing.T) { ctx := t.Context() diff --git a/server/home.go b/server/home.go index ee0c496a..168b70e0 100644 --- a/server/home.go +++ b/server/home.go @@ -2,6 +2,7 @@ package server import ( "context" + "crypto/subtle" "errors" "fmt" "net/http" @@ -24,7 +25,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { if cookie, err := r.Cookie(s.sessionConfig.CookieName); err == nil && cookie.Value != "" { if userID, connectorID, nonce, err := parseSessionCookie(cookie.Value, s.sessionConfig.CookieEncryptionKey); err == nil { session, err := s.storage.GetAuthSession(ctx, userID, connectorID) - if err == nil && session.Nonce == nonce { + if err == nil && subtle.ConstantTimeCompare([]byte(session.Nonce), []byte(nonce)) == 1 { data.LoggedIn = true data.IPAddress = session.IPAddress data.UserAgent = session.UserAgent diff --git a/server/logout.go b/server/logout.go index c3a0699f..12f5aa07 100644 --- a/server/logout.go +++ b/server/logout.go @@ -2,6 +2,7 @@ package server import ( "context" + "crypto/subtle" "errors" "net/http" "net/url" @@ -94,7 +95,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if cookie, err := r.Cookie(s.sessionConfig.CookieName); err == nil && cookie.Value != "" { if uid, cid, nonce, err := parseSessionCookie(cookie.Value, s.sessionConfig.CookieEncryptionKey); err == nil { // Verify the session exists and nonce matches before trusting the cookie. - if session, err := s.storage.GetAuthSession(ctx, uid, cid); err == nil && session.Nonce == nonce { + if session, err := s.storage.GetAuthSession(ctx, uid, cid); err == nil && subtle.ConstantTimeCompare([]byte(session.Nonce), []byte(nonce)) == 1 { userID = uid connectorID = cid s.logger.DebugContext(ctx, "logout: identified user from session cookie", @@ -178,7 +179,7 @@ func (s *Server) handleLogoutCallback(w http.ResponseWriter, r *http.Request) { return } - if session.Nonce != nonce { + if subtle.ConstantTimeCompare([]byte(session.Nonce), []byte(nonce)) != 1 { s.renderError(r, w, http.StatusBadRequest, "Invalid session.") return } @@ -250,13 +251,21 @@ func (s *Server) tryUpstreamLogout(ctx context.Context, userID, connectorID stri } // Check that the session exists — we need it to store logout state. - _, err = s.storage.GetAuthSession(ctx, userID, connectorID) + session, err := s.storage.GetAuthSession(ctx, userID, connectorID) if err != nil { s.logger.DebugContext(ctx, "logout: no auth session for upstream logout, skipping", "user_id", userID, "connector_id", connectorID) return "", false } + // The auth session connector data should keep an id_token that will be used as hint for RP-Initiated logout + if len(session.ConnectorData) > 0 { + connectorData = session.ConnectorData + s.logger.DebugContext(ctx, "logout: using auth_session.ConnectorData", "connector_id", connectorID) + } else if len(connectorData) == 0 { + s.logger.DebugContext(ctx, "logout: no connector data available", "connector_id", connectorID) + } + // Store logout parameters in the session. if err := s.storage.UpdateAuthSession(ctx, userID, connectorID, func(old storage.AuthSession) (storage.AuthSession, error) { old.LogoutState = &storage.LogoutState{ diff --git a/server/logout_test.go b/server/logout_test.go index 4c55b236..05ce93ef 100644 --- a/server/logout_test.go +++ b/server/logout_test.go @@ -12,9 +12,29 @@ import ( "github.com/stretchr/testify/require" + "github.com/dexidp/dex/connector" "github.com/dexidp/dex/storage" ) +// recordingLogoutConnector implements connector.LogoutCallbackConnector and +// records the connectorData it was invoked with so tests can assert what was +// passed down. +type recordingLogoutConnector struct { + gotConnectorData []byte + returnURL string +} + +func (c *recordingLogoutConnector) LogoutURL(_ context.Context, connectorData []byte, _ string) (string, error) { + c.gotConnectorData = connectorData + return c.returnURL, nil +} + +func (c *recordingLogoutConnector) HandleLogoutCallback(_ context.Context, _ *http.Request) error { + return nil +} + +var _ connector.LogoutCallbackConnector = (*recordingLogoutConnector)(nil) + func TestHandleLogoutNoSessions(t *testing.T) { httpServer, server := newTestServer(t, nil) defer httpServer.Close() @@ -293,6 +313,62 @@ func TestDiscoveryWithoutSessions(t *testing.T) { require.Empty(t, d.EndSession) } +// TestHandleLogoutFromCookie tests logout without id_token_hint, +// where the user is identified by their session cookie alone. +func TestHandleLogoutFromCookie(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + ctx := t.Context() + userID := "test-user" + connectorID := "mock" + nonce := "testnonce" + + require.NoError(t, server.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: userID, ConnectorID: connectorID, Nonce: nonce, + CreatedAt: time.Now(), LastActivity: time.Now(), + })) + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/logout", nil) + req.AddCookie(&http.Cookie{ + Name: "dex_session", + Value: sessionCookieValue(userID, connectorID, nonce, server.sessionConfig.CookieEncryptionKey), + }) + server.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + require.Contains(t, rr.Body.String(), "successfully logged out") + + // Session should be deleted. + _, err := server.storage.GetAuthSession(ctx, userID, connectorID) + require.ErrorIs(t, err, storage.ErrNotFound) + + // Cookie should be cleared. + for _, c := range rr.Result().Cookies() { + if c.Name == "dex_session" { + require.Equal(t, -1, c.MaxAge) + } + } +} + +// TestLogoutCallbackWithExpiredSession tests that /logout/callback +// returns an error when the session has expired or been deleted. +func TestLogoutCallbackWithExpiredSession(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + // No session created — cookie points to nonexistent session. + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/logout/callback", nil) + req.AddCookie(&http.Cookie{ + Name: "dex_session", + Value: sessionCookieValue("user-1", "mock", "nonce", server.sessionConfig.CookieEncryptionKey), + }) + server.ServeHTTP(rr, req) + require.Equal(t, http.StatusBadRequest, rr.Code) +} + func TestRevokeRefreshTokensReturnsConnectorData(t *testing.T) { httpServer, server := newTestServerWithSessions(t, nil) defer httpServer.Close() @@ -324,3 +400,68 @@ func TestRevokeRefreshTokensReturnsConnectorData(t *testing.T) { require.Empty(t, os.Refresh) require.Equal(t, expectedConnData, os.ConnectorData) } + +// TestTryUpstreamLogoutPrefersSessionConnectorData verifies that when the auth +// session has ConnectorData stored (from login), it takes precedence over the +// connectorData the caller passes in (which originates from the offline session). +func TestTryUpstreamLogoutPrefersSessionConnectorData(t *testing.T) { + tests := []struct { + name string + sessionConnData []byte + callerConnData []byte + wantConnData []byte + }{ + { + name: "session data wins over caller data", + sessionConnData: []byte(`{"IDToken":"session-token"}`), + callerConnData: []byte(`{"IDToken":"caller-token"}`), + wantConnData: []byte(`{"IDToken":"session-token"}`), + }, + { + name: "caller data used when session data is empty", + sessionConnData: nil, + callerConnData: []byte(`{"IDToken":"caller-token"}`), + wantConnData: []byte(`{"IDToken":"caller-token"}`), + }, + { + name: "empty when neither source has data", + sessionConnData: nil, + callerConnData: nil, + wantConnData: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + ctx := t.Context() + userID := "test-user" + connectorID := "mock" + + // Inject a recording connector with matching ResourceVersion so + // getConnector returns our mock instead of re-opening from storage. + rec := &recordingLogoutConnector{returnURL: "https://upstream.example.com/logout"} + server.mu.Lock() + server.connectors[connectorID] = Connector{ + Type: "mockCallback", + ResourceVersion: "1", + Connector: rec, + } + server.mu.Unlock() + + require.NoError(t, server.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: userID, ConnectorID: connectorID, Nonce: "nonce", + CreatedAt: time.Now(), LastActivity: time.Now(), + ConnectorData: tc.sessionConnData, + })) + + redirectURL, ok := server.tryUpstreamLogout(ctx, userID, connectorID, tc.callerConnData, + "https://dex.example.com/cb", "state-123", "client-123") + require.True(t, ok) + require.Equal(t, "https://upstream.example.com/logout", redirectURL) + require.Equal(t, tc.wantConnData, rec.gotConnectorData) + }) + } +} diff --git a/server/refreshhandlers_test.go b/server/refreshhandlers_test.go index 8db80c31..aec7e32b 100644 --- a/server/refreshhandlers_test.go +++ b/server/refreshhandlers_test.go @@ -336,13 +336,11 @@ func TestRefreshTokenAuthTime(t *testing.T) { "access token should not have auth_time when sessions are disabled") } - // TODO: newIDToken in handleRefreshToken is currently called with time.Time{}, - // so the ID token does not include auth_time. Once fixed, uncomment: - // if tc.wantAuthTime { - // idClaims := decodeJWTClaims(t, resp.IDToken) - // assert.Equal(t, float64(loginTime.Unix()), idClaims["auth_time"], - // "id token auth_time should match UserIdentity.LastLogin") - // } + if tc.wantAuthTime { + idClaims := decodeJWTClaims(t, resp.IDToken) + assert.Equal(t, float64(loginTime.Unix()), idClaims["auth_time"], + "id token auth_time should match UserIdentity.LastLogin") + } }) } } diff --git a/server/session.go b/server/session.go index e4753b47..87795893 100644 --- a/server/session.go +++ b/server/session.go @@ -5,6 +5,7 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rand" + "crypto/subtle" "encoding/base64" "errors" "fmt" @@ -178,7 +179,9 @@ func (s *Server) getValidSession(ctx context.Context, w http.ResponseWriter, r * } // Verify nonce to prevent cookie forgery. - if session.Nonce != nonce { + // Use constant-time comparison to prevent timing attacks that could + // allow an attacker to recover the nonce byte-by-byte. + if subtle.ConstantTimeCompare([]byte(session.Nonce), []byte(nonce)) != 1 { s.logger.DebugContext(ctx, "auth session nonce mismatch") s.clearSessionCookie(w) return nil @@ -258,6 +261,7 @@ func (s *Server) createOrUpdateAuthSession(ctx context.Context, r *http.Request, old.ClientStates = make(map[string]*storage.ClientAuthState) } old.ClientStates[authReq.ClientID] = clientState + old.ConnectorData = authReq.ConnectorData return old, nil }); err != nil { return fmt.Errorf("update auth session: %w", err) @@ -286,6 +290,7 @@ func (s *Server) createOrUpdateAuthSession(ctx context.Context, r *http.Request, UserAgent: r.UserAgent(), AbsoluteExpiry: now.Add(s.sessionConfig.AbsoluteLifetime), IdleExpiry: now.Add(s.sessionConfig.ValidIfNotUsedFor), + ConnectorData: authReq.ConnectorData, } if err := s.storage.CreateAuthSession(ctx, newSession); err != nil { diff --git a/server/session_test.go b/server/session_test.go index 21400b44..7ff0e368 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -1431,3 +1431,639 @@ func TestFinishSessionLogin_MFA(t *testing.T) { assert.Contains(t, redirectURL, "/approval") }) } + +// TestNonceVerificationRejectsForgedCookie verifies that a session cookie +// with a valid (userID, connectorID) but wrong nonce is rejected. +// The nonce comparison uses constant-time comparison to prevent timing attacks. +func TestNonceVerificationRejectsForgedCookie(t *testing.T) { + ctx := t.Context() + s := newTestSessionServer(t) + now := s.now() + + require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", ConnectorID: "mock", Nonce: "real-nonce", + CreatedAt: now.Add(-10 * time.Minute), LastActivity: now.Add(-1 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute), + })) + + tests := []struct { + name string + nonce string + }{ + {"wrong nonce", "wrong-nonce"}, + {"empty nonce", ""}, + {"prefix of real nonce", "real"}, + {"real nonce with suffix", "real-nonce-extra"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := sessionCookieRequest("user-1", "mock", tc.nonce) + w := httptest.NewRecorder() + + session := s.getValidSession(ctx, w, r) + assert.Nil(t, session, "session with forged nonce %q should be rejected", tc.nonce) + + // Cookie should be cleared on nonce mismatch. + for _, c := range w.Result().Cookies() { + if c.Name == "dex_session" { + assert.Equal(t, -1, c.MaxAge, "cookie should be cleared") + } + } + }) + } + + t.Run("correct nonce accepted", func(t *testing.T) { + r := sessionCookieRequest("user-1", "mock", "real-nonce") + w := httptest.NewRecorder() + + session := s.getValidSession(ctx, w, r) + require.NotNil(t, session) + assert.Equal(t, "user-1", session.UserID) + }) +} + +// TestPromptNone tests the prompt=none silent authentication scenarios. +// These verify the code paths in handleConnectorLogin (handlers.go:444-457) +// where prompt=none requires session-based login without any UI. +func TestPromptNone(t *testing.T) { + ctx := t.Context() + + t.Run("valid session with consent issues code silently", func(t *testing.T) { + s := newTestSessionServer(t) + s.skipApproval = false + authReq := setupSessionLoginFixture(t, s) + // Fixture already sets up Consents: {"client-1": {"openid", "email"}} + // and authReq.Scopes = {"openid", "email"} — consent is satisfied. + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + session := s.getValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + redirectURL, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + require.True(t, ok, "session login should succeed") + assert.Empty(t, redirectURL, "should return empty URL when code is issued directly (silent auth)") + }) + + t.Run("valid session without consent returns approval URL", func(t *testing.T) { + s := newTestSessionServer(t) + s.skipApproval = false + now := s.now() + + require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + Nonce: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-1": {Active: true, ExpiresAt: now.Add(24 * time.Hour), LastActivity: now.Add(-1 * time.Minute)}, + }, + CreatedAt: now.Add(-30 * time.Minute), + LastActivity: now.Add(-1 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), + IdleExpiry: now.Add(59 * time.Minute), + })) + require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", + ConnectorID: "mock", + Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"}, + Consents: map[string][]string{}, // No consent for any client. + CreatedAt: now.Add(-1 * time.Hour), + LastLogin: now.Add(-30 * time.Minute), + })) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), + ClientID: "client-1", + ConnectorID: "mock", + Scopes: []string{"openid", "email"}, + RedirectURI: "http://localhost/callback", + MaxAge: -1, + HMACKey: storage.NewHMACKey(crypto.SHA256), + Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq)) + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + session := s.getValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + // In handleConnectorLogin, a non-empty redirectURL with prompt=none + // triggers errInteractionRequired ("Consent required"). + redirectURL, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + require.True(t, ok, "session login should succeed (user is authenticated)") + assert.Contains(t, redirectURL, "/approval", "should return approval URL when consent is missing") + }) + + t.Run("no session returns false", func(t *testing.T) { + s := newTestSessionServer(t) + authReq := storage.AuthRequest{ConnectorID: "mock"} + r := httptest.NewRequest(http.MethodGet, "/", nil) // No cookie. + w := httptest.NewRecorder() + + // In handleConnectorLogin, this triggers errLoginRequired. + _, ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.False(t, ok, "should fail without session") + }) + + t.Run("SSO available issues code silently", func(t *testing.T) { + s := newTestSessionServer(t) + s.skipApproval = true + now := s.now() + + require.NoError(t, s.storage.CreateClient(ctx, storage.Client{ + ID: "client-a", Secret: "secret", Name: "A", SSOSharedWith: []string{"client-b"}, + })) + + require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", ConnectorID: "mock", Nonce: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": {Active: true, ExpiresAt: now.Add(24 * time.Hour), LastActivity: now.Add(-1 * time.Minute)}, + }, + CreatedAt: now.Add(-30 * time.Minute), LastActivity: now.Add(-1 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute), + })) + require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", ConnectorID: "mock", + Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"}, + Consents: map[string][]string{}, + CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-30 * time.Minute), + })) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), ClientID: "client-b", ConnectorID: "mock", + Scopes: []string{"openid"}, RedirectURI: "http://localhost/callback", + MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq)) + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + session := s.getValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + redirectURL, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + require.True(t, ok, "SSO silent login should succeed") + assert.Empty(t, redirectURL, "should issue code silently via SSO (skipApproval=true, openid-only)") + + // Verify SSO created a new client state. + updated, err := s.storage.GetAuthSession(ctx, "user-1", "mock") + require.NoError(t, err) + assert.Contains(t, updated.ClientStates, "client-b", "SSO should create client state for target") + }) + + t.Run("MFA required returns redirect not silent", func(t *testing.T) { + // This is the prompt=none + MFA case: finishSessionLogin returns MFA redirect URL. + // In handleConnectorLogin, this is a successful (ok=true) redirect, not errLoginRequired. + s := newTestSessionServer(t) + s.skipApproval = true + s.mfaProviders = map[string]MFAProvider{ + "totp": NewTOTPProvider("test-issuer", nil), + } + + require.NoError(t, s.storage.CreateConnector(ctx, storage.Connector{ + ID: "mock", Type: "ldap", Name: "Mock", ResourceVersion: "1", + })) + s.mu.Lock() + s.connectors = map[string]Connector{"mock": {Type: "ldap", ResourceVersion: "1"}} + s.mu.Unlock() + require.NoError(t, s.storage.CreateClient(ctx, storage.Client{ + ID: "client-1", Secret: "secret", Name: "Test", MFAChain: []string{"totp"}, + })) + + authReq := setupSessionLoginFixture(t, s) + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + redirectURL, ok := s.trySessionLogin(ctx, r, w, &authReq) + require.True(t, ok) + assert.Contains(t, redirectURL, "/mfa/totp", "prompt=none with MFA should redirect to MFA page") + }) +} + +// TestPromptConsent tests that prompt=consent forces the approval screen +// even when consent is already given. +func TestPromptConsent(t *testing.T) { + ctx := t.Context() + + t.Run("ForceApprovalPrompt overrides existing consent in session login", func(t *testing.T) { + s := newTestSessionServer(t) + s.skipApproval = false + authReq := setupSessionLoginFixture(t, s) + + // Set ForceApprovalPrompt (set by prompt=consent in parseAuthorizationRequest). + require.NoError(t, s.storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) { + a.ForceApprovalPrompt = true + return a, nil + })) + authReq.ForceApprovalPrompt = true + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + redirectURL, ok := s.trySessionLogin(ctx, r, w, &authReq) + require.True(t, ok) + assert.Contains(t, redirectURL, "/approval", "should show approval even though consent exists") + }) + + t.Run("login+consent parsed correctly", func(t *testing.T) { + prompt, err := ParsePrompt("login consent") + require.NoError(t, err) + assert.True(t, prompt.Login(), "login flag should be set") + assert.True(t, prompt.Consent(), "consent flag should be set") + }) +} + +// TestSSO_ConsentAndMFA tests SSO interactions with consent and MFA. +func TestSSO_ConsentAndMFA(t *testing.T) { + ctx := t.Context() + + // setupSSOFixture creates a two-client SSO scenario where client-a shares with client-b. + setupSSOFixture := func(t *testing.T, s *Server, consentsForB []string) storage.AuthRequest { + t.Helper() + now := s.now() + + require.NoError(t, s.storage.CreateClient(ctx, storage.Client{ + ID: "client-a", Secret: "secret", Name: "A", SSOSharedWith: []string{"client-b"}, + })) + + require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", ConnectorID: "mock", Nonce: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": {Active: true, ExpiresAt: now.Add(24 * time.Hour), LastActivity: now.Add(-1 * time.Minute)}, + }, + CreatedAt: now.Add(-30 * time.Minute), LastActivity: now.Add(-1 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute), + })) + + consents := map[string][]string{} + if len(consentsForB) > 0 { + consents["client-b"] = consentsForB + } + require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", ConnectorID: "mock", + Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"}, + Consents: consents, + CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-30 * time.Minute), + })) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), ClientID: "client-b", ConnectorID: "mock", + Scopes: []string{"openid", "email"}, RedirectURI: "http://localhost/callback", + MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq)) + return authReq + } + + t.Run("SSO without consent for target shows approval", func(t *testing.T) { + s := newTestSessionServer(t) + s.skipApproval = false + authReq := setupSSOFixture(t, s, nil) // No consent for client-b. + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + session := s.getValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + redirectURL, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + require.True(t, ok, "SSO login should succeed") + assert.Contains(t, redirectURL, "/approval", "should show approval when target client has no consent") + }) + + t.Run("SSO with consent for target skips approval", func(t *testing.T) { + s := newTestSessionServer(t) + s.skipApproval = false + authReq := setupSSOFixture(t, s, []string{"openid", "email"}) + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + session := s.getValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + redirectURL, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + require.True(t, ok, "SSO login should succeed") + assert.Empty(t, redirectURL, "should skip approval when consent exists for target client") + }) + + t.Run("SSO with MFA required on target client redirects to MFA", func(t *testing.T) { + s := newTestSessionServer(t) + s.skipApproval = true + s.mfaProviders = map[string]MFAProvider{ + "totp": NewTOTPProvider("test-issuer", nil), + } + + require.NoError(t, s.storage.CreateConnector(ctx, storage.Connector{ + ID: "mock", Type: "ldap", Name: "Mock", ResourceVersion: "1", + })) + s.mu.Lock() + s.connectors = map[string]Connector{"mock": {Type: "ldap", ResourceVersion: "1"}} + s.mu.Unlock() + + // client-b requires MFA. + require.NoError(t, s.storage.CreateClient(ctx, storage.Client{ + ID: "client-b", Secret: "secret", Name: "B", MFAChain: []string{"totp"}, + })) + + authReq := setupSSOFixture(t, s, []string{"openid", "email"}) + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + session := s.getValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + redirectURL, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + require.True(t, ok) + assert.Contains(t, redirectURL, "/mfa/totp", "SSO to MFA-requiring client should redirect to MFA") + }) + + t.Run("SSO source without MFA target with MFA enforces MFA", func(t *testing.T) { + s := newTestSessionServer(t) + s.skipApproval = true + s.mfaProviders = map[string]MFAProvider{ + "totp": NewTOTPProvider("test-issuer", nil), + } + + require.NoError(t, s.storage.CreateConnector(ctx, storage.Connector{ + ID: "mock", Type: "ldap", Name: "Mock", ResourceVersion: "1", + })) + s.mu.Lock() + s.connectors = map[string]Connector{"mock": {Type: "ldap", ResourceVersion: "1"}} + s.mu.Unlock() + + // client-a has NO MFA, client-b requires MFA. + require.NoError(t, s.storage.CreateClient(ctx, storage.Client{ + ID: "client-a", Secret: "secret", Name: "A", SSOSharedWith: []string{"client-b"}, + MFAChain: []string{}, // Explicitly no MFA. + })) + require.NoError(t, s.storage.CreateClient(ctx, storage.Client{ + ID: "client-b", Secret: "secret", Name: "B", + MFAChain: []string{"totp"}, + })) + + now := s.now() + require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", ConnectorID: "mock", Nonce: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": {Active: true, ExpiresAt: now.Add(24 * time.Hour), LastActivity: now.Add(-1 * time.Minute)}, + }, + CreatedAt: now.Add(-30 * time.Minute), LastActivity: now.Add(-1 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute), + })) + require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", ConnectorID: "mock", + Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"}, + Consents: map[string][]string{}, + CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-30 * time.Minute), + })) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), ClientID: "client-b", ConnectorID: "mock", + Scopes: []string{"openid"}, RedirectURI: "http://localhost/callback", + MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq)) + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + session := s.getValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + redirectURL, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + require.True(t, ok) + assert.Contains(t, redirectURL, "/mfa/totp", + "SSO from no-MFA source to MFA-requiring target must enforce MFA") + }) +} + +// TestUpdateSessionTokenIssuedAt tests session activity tracking +// when tokens are issued via sendCodeResponse (handlers.go:1016). +func TestUpdateSessionTokenIssuedAt(t *testing.T) { + ctx := t.Context() + + t.Run("updates session fields for correct client", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.now() + + require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", ConnectorID: "mock", Nonce: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-1": {Active: true, ExpiresAt: now.Add(24 * time.Hour), LastActivity: now.Add(-10 * time.Minute)}, + "client-2": {Active: true, ExpiresAt: now.Add(24 * time.Hour), LastActivity: now.Add(-10 * time.Minute)}, + }, + CreatedAt: now.Add(-1 * time.Hour), LastActivity: now.Add(-10 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(50 * time.Minute), + })) + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + s.updateSessionTokenIssuedAt(r, "client-1") + + session, err := s.storage.GetAuthSession(ctx, "user-1", "mock") + require.NoError(t, err) + + assert.Equal(t, now, session.LastActivity, "session LastActivity should be updated") + assert.Equal(t, now.Add(s.sessionConfig.ValidIfNotUsedFor), session.IdleExpiry, "IdleExpiry should be extended") + assert.Equal(t, now, session.ClientStates["client-1"].LastTokenIssuedAt, "client-1 LastTokenIssuedAt should be set") + assert.Equal(t, now, session.ClientStates["client-1"].LastActivity, "client-1 LastActivity should be updated") + // client-2 should be untouched. + assert.Equal(t, now.Add(-10*time.Minute), session.ClientStates["client-2"].LastActivity, + "client-2 should not be affected") + }) + + t.Run("noop when sessions disabled", func(t *testing.T) { + s := newTestSessionServer(t) + s.sessionConfig = nil + + r := httptest.NewRequest(http.MethodGet, "/", nil) + // Should not panic. + s.updateSessionTokenIssuedAt(r, "any-client") + }) +} + +// TestIdleExpiryExtension verifies that session activity pushes +// IdleExpiry forward, preventing premature session expiration. +func TestIdleExpiryExtension(t *testing.T) { + ctx := t.Context() + + t.Run("createOrUpdateAuthSession extends IdleExpiry", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.now() + + // Create an existing session with IdleExpiry close to now. + require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", ConnectorID: "mock", Nonce: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{}, + CreatedAt: now.Add(-50 * time.Minute), + LastActivity: now.Add(-50 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), + IdleExpiry: now.Add(10 * time.Minute), // Only 10 minutes left. + })) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + authReq := storage.AuthRequest{ + ClientID: "client-1", ConnectorID: "mock", + Claims: storage.Claims{UserID: "user-1"}, + } + + err := s.createOrUpdateAuthSession(ctx, r, w, authReq, false) + require.NoError(t, err) + + session, err := s.storage.GetAuthSession(ctx, "user-1", "mock") + require.NoError(t, err) + assert.Equal(t, now.Add(s.sessionConfig.ValidIfNotUsedFor), session.IdleExpiry, + "IdleExpiry should be reset to now + ValidIfNotUsedFor") + }) + + t.Run("finishSessionLogin extends IdleExpiry", func(t *testing.T) { + s := newTestSessionServer(t) + s.skipApproval = true + now := s.now() + + require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", ConnectorID: "mock", Nonce: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-1": {Active: true, ExpiresAt: now.Add(24 * time.Hour), LastActivity: now.Add(-50 * time.Minute)}, + }, + CreatedAt: now.Add(-50 * time.Minute), LastActivity: now.Add(-50 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), + IdleExpiry: now.Add(10 * time.Minute), // About to expire. + })) + require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", ConnectorID: "mock", + Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"}, + Consents: map[string][]string{}, + CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-50 * time.Minute), + })) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), ClientID: "client-1", ConnectorID: "mock", + Scopes: []string{"openid"}, RedirectURI: "http://localhost/callback", + MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq)) + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + session := s.getValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + _, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + require.True(t, ok) + + updated, err := s.storage.GetAuthSession(ctx, "user-1", "mock") + require.NoError(t, err) + assert.Equal(t, now.Add(s.sessionConfig.ValidIfNotUsedFor), updated.IdleExpiry, + "IdleExpiry should be extended after session login") + }) +} + +// TestSSO_Unidirectional verifies that SSO sharing is one-way: +// A sharing with B does NOT mean B shares with A. +func TestSSO_Unidirectional(t *testing.T) { + ctx := t.Context() + + setup := func(t *testing.T, s *Server, loginClient, targetClient string) (storage.AuthRequest, *storage.AuthSession) { + t.Helper() + now := s.now() + + require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", ConnectorID: "mock", Nonce: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + loginClient: {Active: true, ExpiresAt: now.Add(24 * time.Hour), LastActivity: now.Add(-1 * time.Minute)}, + }, + CreatedAt: now.Add(-30 * time.Minute), LastActivity: now.Add(-1 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute), + })) + require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", ConnectorID: "mock", + Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"}, + Consents: map[string][]string{}, + CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-30 * time.Minute), + })) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), ClientID: targetClient, ConnectorID: "mock", + Scopes: []string{"openid"}, RedirectURI: "http://localhost/callback", + MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq)) + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + session := s.getValidAuthSession(ctx, w, r, &authReq) + return authReq, session + } + + t.Run("A shares with B, login A request B succeeds", func(t *testing.T) { + s := newTestSessionServer(t) + s.skipApproval = true + + require.NoError(t, s.storage.CreateClient(ctx, storage.Client{ + ID: "client-a", Secret: "s", Name: "A", SSOSharedWith: []string{"client-b"}, + })) + require.NoError(t, s.storage.CreateClient(ctx, storage.Client{ + ID: "client-b", Secret: "s", Name: "B", SSOSharedWith: []string{}, // Does NOT share back. + })) + + authReq, session := setup(t, s, "client-a", "client-b") + require.NotNil(t, session) + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + _, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + assert.True(t, ok, "A→B SSO should succeed") + }) + + t.Run("B does not share with A, login B request A fails", func(t *testing.T) { + s := newTestSessionServer(t) + s.skipApproval = true + + require.NoError(t, s.storage.CreateClient(ctx, storage.Client{ + ID: "client-a", Secret: "s", Name: "A", SSOSharedWith: []string{"client-b"}, + })) + require.NoError(t, s.storage.CreateClient(ctx, storage.Client{ + ID: "client-b", Secret: "s", Name: "B", SSOSharedWith: []string{}, // Does NOT share. + })) + + authReq, session := setup(t, s, "client-b", "client-a") + require.NotNil(t, session) + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + _, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + assert.False(t, ok, "B→A SSO should fail because B does not share with A") + }) +} + +// TestRememberMeDefault tests that the rememberMeDefault helper +// returns the correct value based on session configuration. +func TestRememberMeDefault(t *testing.T) { + t.Run("sessions disabled returns nil", func(t *testing.T) { + s := &Server{sessionConfig: nil} + assert.Nil(t, s.rememberMeDefault()) + }) + + t.Run("default false", func(t *testing.T) { + s := &Server{sessionConfig: &SessionConfig{RememberMeCheckedByDefault: false}} + v := s.rememberMeDefault() + require.NotNil(t, v) + assert.False(t, *v) + }) + + t.Run("default true", func(t *testing.T) { + s := &Server{sessionConfig: &SessionConfig{RememberMeCheckedByDefault: true}} + v := s.rememberMeDefault() + require.NotNil(t, v) + assert.True(t, *v) + }) +} diff --git a/storage/conformance/conformance.go b/storage/conformance/conformance.go index 33ec950c..1810421b 100644 --- a/storage/conformance/conformance.go +++ b/storage/conformance/conformance.go @@ -2,6 +2,7 @@ package conformance import ( + "bytes" "crypto/ecdsa" "reflect" "sort" @@ -1388,6 +1389,7 @@ func testAuthSessionCRUD(t *testing.T, s storage.Storage) { UserAgent: "TestBrowser/1.0", AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(1 * time.Hour), + ConnectorData: []byte(`{"RefreshToken":"dGVzdA==","IDToken":"ZXlKaGJHY21PaUpTVXpJMU5pSjk="}`), } // Create. @@ -1418,8 +1420,9 @@ func testAuthSessionCRUD(t *testing.T, s storage.Storage) { t.Errorf("auth session retrieved from storage did not match: %s", diff) } - // Update: add a new client state. + // Update: add a new client state and rotate connector data. newNow := now.Add(time.Minute) + updatedConnectorData := []byte(`{"RefreshToken":"bmV3","IDToken":"bmV3LWlk"}`) if err := s.UpdateAuthSession(ctx, session.UserID, session.ConnectorID, func(old storage.AuthSession) (storage.AuthSession, error) { old.ClientStates["client2"] = &storage.ClientAuthState{ Active: true, @@ -1427,6 +1430,7 @@ func testAuthSessionCRUD(t *testing.T, s storage.Storage) { LastActivity: newNow, } old.LastActivity = newNow + old.ConnectorData = updatedConnectorData return old, nil }); err != nil { t.Fatalf("update auth session: %v", err) @@ -1443,6 +1447,9 @@ func testAuthSessionCRUD(t *testing.T, s storage.Storage) { if got.ClientStates["client2"] == nil { t.Fatal("expected client2 state to exist") } + if !bytes.Equal(got.ConnectorData, updatedConnectorData) { + t.Fatalf("expected updated connector data %q, got %q", updatedConnectorData, got.ConnectorData) + } // List and verify. sessions, err := s.ListAuthSessions(ctx) diff --git a/storage/ent/client/authsession.go b/storage/ent/client/authsession.go index b4cdfe81..5ed9dc86 100644 --- a/storage/ent/client/authsession.go +++ b/storage/ent/client/authsession.go @@ -31,6 +31,7 @@ func (d *Database) CreateAuthSession(ctx context.Context, session storage.AuthSe SetUserAgent(session.UserAgent). SetAbsoluteExpiry(session.AbsoluteExpiry.UTC()). SetIdleExpiry(session.IdleExpiry.UTC()). + SetConnectorData(session.ConnectorData). Save(ctx) if err != nil { return convertDBError("create auth session: %w", err) @@ -106,6 +107,7 @@ func (d *Database) UpdateAuthSession(ctx context.Context, userID, connectorID st SetUserAgent(newSession.UserAgent). SetAbsoluteExpiry(newSession.AbsoluteExpiry.UTC()). SetIdleExpiry(newSession.IdleExpiry.UTC()). + SetConnectorData(newSession.ConnectorData). Save(ctx) if err != nil { return rollback(tx, "update auth session updating: %w", err) diff --git a/storage/ent/client/types.go b/storage/ent/client/types.go index 4a6e2bc7..dc40e271 100644 --- a/storage/ent/client/types.go +++ b/storage/ent/client/types.go @@ -244,6 +244,10 @@ func toStorageAuthSession(s *db.AuthSession) storage.AuthSession { IdleExpiry: s.IdleExpiry, } + if s.ConnectorData != nil { + result.ConnectorData = *s.ConnectorData + } + if s.ClientStates != nil { if err := json.Unmarshal(s.ClientStates, &result.ClientStates); err != nil { panic(err) diff --git a/storage/ent/db/authsession.go b/storage/ent/db/authsession.go index 6ced0680..a20a49fd 100644 --- a/storage/ent/db/authsession.go +++ b/storage/ent/db/authsession.go @@ -36,8 +36,10 @@ type AuthSession struct { // AbsoluteExpiry holds the value of the "absolute_expiry" field. AbsoluteExpiry time.Time `json:"absolute_expiry,omitempty"` // IdleExpiry holds the value of the "idle_expiry" field. - IdleExpiry time.Time `json:"idle_expiry,omitempty"` - selectValues sql.SelectValues + IdleExpiry time.Time `json:"idle_expiry,omitempty"` + // ConnectorData holds the value of the "connector_data" field. + ConnectorData *[]byte `json:"connector_data,omitempty"` + selectValues sql.SelectValues } // scanValues returns the types for scanning values from sql.Rows. @@ -45,7 +47,7 @@ func (*AuthSession) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case authsession.FieldClientStates: + case authsession.FieldClientStates, authsession.FieldConnectorData: values[i] = new([]byte) case authsession.FieldID, authsession.FieldUserID, authsession.FieldConnectorID, authsession.FieldNonce, authsession.FieldIPAddress, authsession.FieldUserAgent: values[i] = new(sql.NullString) @@ -132,6 +134,12 @@ func (_m *AuthSession) assignValues(columns []string, values []any) error { } else if value.Valid { _m.IdleExpiry = value.Time } + case authsession.FieldConnectorData: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field connector_data", values[i]) + } else if value != nil { + _m.ConnectorData = value + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -197,6 +205,11 @@ func (_m *AuthSession) String() string { builder.WriteString(", ") builder.WriteString("idle_expiry=") builder.WriteString(_m.IdleExpiry.Format(time.ANSIC)) + builder.WriteString(", ") + if v := _m.ConnectorData; v != nil { + builder.WriteString("connector_data=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } builder.WriteByte(')') return builder.String() } diff --git a/storage/ent/db/authsession/authsession.go b/storage/ent/db/authsession/authsession.go index fc1cd5ed..6315f63d 100644 --- a/storage/ent/db/authsession/authsession.go +++ b/storage/ent/db/authsession/authsession.go @@ -31,6 +31,8 @@ const ( FieldAbsoluteExpiry = "absolute_expiry" // FieldIdleExpiry holds the string denoting the idle_expiry field in the database. FieldIdleExpiry = "idle_expiry" + // FieldConnectorData holds the string denoting the connector_data field in the database. + FieldConnectorData = "connector_data" // Table holds the table name of the authsession in the database. Table = "auth_sessions" ) @@ -48,6 +50,7 @@ var Columns = []string{ FieldUserAgent, FieldAbsoluteExpiry, FieldIdleExpiry, + FieldConnectorData, } // ValidColumn reports if the column name is valid (part of the table columns). diff --git a/storage/ent/db/authsession/where.go b/storage/ent/db/authsession/where.go index 193f1133..7ef61428 100644 --- a/storage/ent/db/authsession/where.go +++ b/storage/ent/db/authsession/where.go @@ -114,6 +114,11 @@ func IdleExpiry(v time.Time) predicate.AuthSession { return predicate.AuthSession(sql.FieldEQ(FieldIdleExpiry, v)) } +// ConnectorData applies equality check predicate on the "connector_data" field. It's identical to ConnectorDataEQ. +func ConnectorData(v []byte) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldConnectorData, v)) +} + // UserIDEQ applies the EQ predicate on the "user_id" field. func UserIDEQ(v string) predicate.AuthSession { return predicate.AuthSession(sql.FieldEQ(FieldUserID, v)) @@ -639,6 +644,56 @@ func IdleExpiryLTE(v time.Time) predicate.AuthSession { return predicate.AuthSession(sql.FieldLTE(FieldIdleExpiry, v)) } +// ConnectorDataEQ applies the EQ predicate on the "connector_data" field. +func ConnectorDataEQ(v []byte) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldConnectorData, v)) +} + +// ConnectorDataNEQ applies the NEQ predicate on the "connector_data" field. +func ConnectorDataNEQ(v []byte) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNEQ(FieldConnectorData, v)) +} + +// ConnectorDataIn applies the In predicate on the "connector_data" field. +func ConnectorDataIn(vs ...[]byte) predicate.AuthSession { + return predicate.AuthSession(sql.FieldIn(FieldConnectorData, vs...)) +} + +// ConnectorDataNotIn applies the NotIn predicate on the "connector_data" field. +func ConnectorDataNotIn(vs ...[]byte) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNotIn(FieldConnectorData, vs...)) +} + +// ConnectorDataGT applies the GT predicate on the "connector_data" field. +func ConnectorDataGT(v []byte) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGT(FieldConnectorData, v)) +} + +// ConnectorDataGTE applies the GTE predicate on the "connector_data" field. +func ConnectorDataGTE(v []byte) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGTE(FieldConnectorData, v)) +} + +// ConnectorDataLT applies the LT predicate on the "connector_data" field. +func ConnectorDataLT(v []byte) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLT(FieldConnectorData, v)) +} + +// ConnectorDataLTE applies the LTE predicate on the "connector_data" field. +func ConnectorDataLTE(v []byte) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLTE(FieldConnectorData, v)) +} + +// ConnectorDataIsNil applies the IsNil predicate on the "connector_data" field. +func ConnectorDataIsNil() predicate.AuthSession { + return predicate.AuthSession(sql.FieldIsNull(FieldConnectorData)) +} + +// ConnectorDataNotNil applies the NotNil predicate on the "connector_data" field. +func ConnectorDataNotNil() predicate.AuthSession { + return predicate.AuthSession(sql.FieldNotNull(FieldConnectorData)) +} + // And groups predicates with the AND operator between them. func And(predicates ...predicate.AuthSession) predicate.AuthSession { return predicate.AuthSession(sql.AndPredicates(predicates...)) diff --git a/storage/ent/db/authsession_create.go b/storage/ent/db/authsession_create.go index 0dc99e76..db8e36f2 100644 --- a/storage/ent/db/authsession_create.go +++ b/storage/ent/db/authsession_create.go @@ -96,6 +96,12 @@ func (_c *AuthSessionCreate) SetIdleExpiry(v time.Time) *AuthSessionCreate { return _c } +// SetConnectorData sets the "connector_data" field. +func (_c *AuthSessionCreate) SetConnectorData(v []byte) *AuthSessionCreate { + _c.mutation.SetConnectorData(v) + return _c +} + // SetID sets the "id" field. func (_c *AuthSessionCreate) SetID(v string) *AuthSessionCreate { _c.mutation.SetID(v) @@ -274,6 +280,10 @@ func (_c *AuthSessionCreate) createSpec() (*AuthSession, *sqlgraph.CreateSpec) { _spec.SetField(authsession.FieldIdleExpiry, field.TypeTime, value) _node.IdleExpiry = value } + if value, ok := _c.mutation.ConnectorData(); ok { + _spec.SetField(authsession.FieldConnectorData, field.TypeBytes, value) + _node.ConnectorData = &value + } return _node, _spec } diff --git a/storage/ent/db/authsession_update.go b/storage/ent/db/authsession_update.go index d80e682b..5af22877 100644 --- a/storage/ent/db/authsession_update.go +++ b/storage/ent/db/authsession_update.go @@ -160,6 +160,18 @@ func (_u *AuthSessionUpdate) SetNillableIdleExpiry(v *time.Time) *AuthSessionUpd return _u } +// SetConnectorData sets the "connector_data" field. +func (_u *AuthSessionUpdate) SetConnectorData(v []byte) *AuthSessionUpdate { + _u.mutation.SetConnectorData(v) + return _u +} + +// ClearConnectorData clears the value of the "connector_data" field. +func (_u *AuthSessionUpdate) ClearConnectorData() *AuthSessionUpdate { + _u.mutation.ClearConnectorData() + return _u +} + // Mutation returns the AuthSessionMutation object of the builder. func (_u *AuthSessionUpdate) Mutation() *AuthSessionMutation { return _u.mutation @@ -254,6 +266,12 @@ func (_u *AuthSessionUpdate) sqlSave(ctx context.Context) (_node int, err error) if value, ok := _u.mutation.IdleExpiry(); ok { _spec.SetField(authsession.FieldIdleExpiry, field.TypeTime, value) } + if value, ok := _u.mutation.ConnectorData(); ok { + _spec.SetField(authsession.FieldConnectorData, field.TypeBytes, value) + } + if _u.mutation.ConnectorDataCleared() { + _spec.ClearField(authsession.FieldConnectorData, field.TypeBytes) + } if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { if _, ok := err.(*sqlgraph.NotFoundError); ok { err = &NotFoundError{authsession.Label} @@ -406,6 +424,18 @@ func (_u *AuthSessionUpdateOne) SetNillableIdleExpiry(v *time.Time) *AuthSession return _u } +// SetConnectorData sets the "connector_data" field. +func (_u *AuthSessionUpdateOne) SetConnectorData(v []byte) *AuthSessionUpdateOne { + _u.mutation.SetConnectorData(v) + return _u +} + +// ClearConnectorData clears the value of the "connector_data" field. +func (_u *AuthSessionUpdateOne) ClearConnectorData() *AuthSessionUpdateOne { + _u.mutation.ClearConnectorData() + return _u +} + // Mutation returns the AuthSessionMutation object of the builder. func (_u *AuthSessionUpdateOne) Mutation() *AuthSessionMutation { return _u.mutation @@ -530,6 +560,12 @@ func (_u *AuthSessionUpdateOne) sqlSave(ctx context.Context) (_node *AuthSession if value, ok := _u.mutation.IdleExpiry(); ok { _spec.SetField(authsession.FieldIdleExpiry, field.TypeTime, value) } + if value, ok := _u.mutation.ConnectorData(); ok { + _spec.SetField(authsession.FieldConnectorData, field.TypeBytes, value) + } + if _u.mutation.ConnectorDataCleared() { + _spec.ClearField(authsession.FieldConnectorData, field.TypeBytes) + } _node = &AuthSession{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues diff --git a/storage/ent/db/migrate/schema.go b/storage/ent/db/migrate/schema.go index a6050cb3..f66b1254 100644 --- a/storage/ent/db/migrate/schema.go +++ b/storage/ent/db/migrate/schema.go @@ -82,6 +82,7 @@ var ( {Name: "user_agent", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "absolute_expiry", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, {Name: "idle_expiry", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, + {Name: "connector_data", Type: field.TypeBytes, Nullable: true}, } // AuthSessionsTable holds the schema information for the "auth_sessions" table. AuthSessionsTable = &schema.Table{ diff --git a/storage/ent/db/mutation.go b/storage/ent/db/mutation.go index a21c6576..49a651b2 100644 --- a/storage/ent/db/mutation.go +++ b/storage/ent/db/mutation.go @@ -3154,6 +3154,7 @@ type AuthSessionMutation struct { user_agent *string absolute_expiry *time.Time idle_expiry *time.Time + connector_data *[]byte clearedFields map[string]struct{} done bool oldValue func(context.Context) (*AuthSession, error) @@ -3624,6 +3625,55 @@ func (m *AuthSessionMutation) ResetIdleExpiry() { m.idle_expiry = nil } +// SetConnectorData sets the "connector_data" field. +func (m *AuthSessionMutation) SetConnectorData(b []byte) { + m.connector_data = &b +} + +// ConnectorData returns the value of the "connector_data" field in the mutation. +func (m *AuthSessionMutation) ConnectorData() (r []byte, exists bool) { + v := m.connector_data + if v == nil { + return + } + return *v, true +} + +// OldConnectorData returns the old "connector_data" field's value of the AuthSession entity. +// If the AuthSession object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthSessionMutation) OldConnectorData(ctx context.Context) (v *[]byte, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldConnectorData is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldConnectorData requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldConnectorData: %w", err) + } + return oldValue.ConnectorData, nil +} + +// ClearConnectorData clears the value of the "connector_data" field. +func (m *AuthSessionMutation) ClearConnectorData() { + m.connector_data = nil + m.clearedFields[authsession.FieldConnectorData] = struct{}{} +} + +// ConnectorDataCleared returns if the "connector_data" field was cleared in this mutation. +func (m *AuthSessionMutation) ConnectorDataCleared() bool { + _, ok := m.clearedFields[authsession.FieldConnectorData] + return ok +} + +// ResetConnectorData resets all changes to the "connector_data" field. +func (m *AuthSessionMutation) ResetConnectorData() { + m.connector_data = nil + delete(m.clearedFields, authsession.FieldConnectorData) +} + // Where appends a list predicates to the AuthSessionMutation builder. func (m *AuthSessionMutation) Where(ps ...predicate.AuthSession) { m.predicates = append(m.predicates, ps...) @@ -3658,7 +3708,7 @@ func (m *AuthSessionMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *AuthSessionMutation) Fields() []string { - fields := make([]string, 0, 10) + fields := make([]string, 0, 11) if m.user_id != nil { fields = append(fields, authsession.FieldUserID) } @@ -3689,6 +3739,9 @@ func (m *AuthSessionMutation) Fields() []string { if m.idle_expiry != nil { fields = append(fields, authsession.FieldIdleExpiry) } + if m.connector_data != nil { + fields = append(fields, authsession.FieldConnectorData) + } return fields } @@ -3717,6 +3770,8 @@ func (m *AuthSessionMutation) Field(name string) (ent.Value, bool) { return m.AbsoluteExpiry() case authsession.FieldIdleExpiry: return m.IdleExpiry() + case authsession.FieldConnectorData: + return m.ConnectorData() } return nil, false } @@ -3746,6 +3801,8 @@ func (m *AuthSessionMutation) OldField(ctx context.Context, name string) (ent.Va return m.OldAbsoluteExpiry(ctx) case authsession.FieldIdleExpiry: return m.OldIdleExpiry(ctx) + case authsession.FieldConnectorData: + return m.OldConnectorData(ctx) } return nil, fmt.Errorf("unknown AuthSession field %s", name) } @@ -3825,6 +3882,13 @@ func (m *AuthSessionMutation) SetField(name string, value ent.Value) error { } m.SetIdleExpiry(v) return nil + case authsession.FieldConnectorData: + v, ok := value.([]byte) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetConnectorData(v) + return nil } return fmt.Errorf("unknown AuthSession field %s", name) } @@ -3854,7 +3918,11 @@ func (m *AuthSessionMutation) AddField(name string, value ent.Value) error { // ClearedFields returns all nullable fields that were cleared during this // mutation. func (m *AuthSessionMutation) ClearedFields() []string { - return nil + var fields []string + if m.FieldCleared(authsession.FieldConnectorData) { + fields = append(fields, authsession.FieldConnectorData) + } + return fields } // FieldCleared returns a boolean indicating if a field with the given name was @@ -3867,6 +3935,11 @@ func (m *AuthSessionMutation) FieldCleared(name string) bool { // ClearField clears the value of the field with the given name. It returns an // error if the field is not defined in the schema. func (m *AuthSessionMutation) ClearField(name string) error { + switch name { + case authsession.FieldConnectorData: + m.ClearConnectorData() + return nil + } return fmt.Errorf("unknown AuthSession nullable field %s", name) } @@ -3904,6 +3977,9 @@ func (m *AuthSessionMutation) ResetField(name string) error { case authsession.FieldIdleExpiry: m.ResetIdleExpiry() return nil + case authsession.FieldConnectorData: + m.ResetConnectorData() + return nil } return fmt.Errorf("unknown AuthSession field %s", name) } diff --git a/storage/ent/schema/authsession.go b/storage/ent/schema/authsession.go index 0b641b7f..aaa8ec36 100644 --- a/storage/ent/schema/authsession.go +++ b/storage/ent/schema/authsession.go @@ -41,6 +41,9 @@ func (AuthSession) Fields() []ent.Field { SchemaType(timeSchema), field.Time("idle_expiry"). SchemaType(timeSchema), + field.Bytes("connector_data"). + Nillable(). + Optional(), } } diff --git a/storage/etcd/types.go b/storage/etcd/types.go index aabb16f6..8bf5eff7 100644 --- a/storage/etcd/types.go +++ b/storage/etcd/types.go @@ -336,6 +336,7 @@ type AuthSession struct { UserAgent string `json:"user_agent,omitempty"` AbsoluteExpiry time.Time `json:"absolute_expiry"` IdleExpiry time.Time `json:"idle_expiry"` + ConnectorData []byte `json:"connector_data,omitempty"` } func fromStorageAuthSession(s storage.AuthSession) AuthSession { @@ -350,6 +351,7 @@ func fromStorageAuthSession(s storage.AuthSession) AuthSession { UserAgent: s.UserAgent, AbsoluteExpiry: s.AbsoluteExpiry, IdleExpiry: s.IdleExpiry, + ConnectorData: s.ConnectorData, } } @@ -365,6 +367,7 @@ func toStorageAuthSession(s AuthSession) storage.AuthSession { UserAgent: s.UserAgent, AbsoluteExpiry: s.AbsoluteExpiry, IdleExpiry: s.IdleExpiry, + ConnectorData: s.ConnectorData, } if result.ClientStates == nil { result.ClientStates = make(map[string]*storage.ClientAuthState) diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index d936865a..b03f7f24 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -1022,6 +1022,7 @@ type AuthSession struct { AbsoluteExpiry time.Time `json:"absoluteExpiry,omitempty"` IdleExpiry time.Time `json:"idleExpiry,omitempty"` LogoutState *storage.LogoutState `json:"logoutState,omitempty"` + ConnectorData []byte `json:"connectorData,omitempty"` } // AuthSessionList is a list of AuthSessions. @@ -1052,6 +1053,7 @@ func (cli *client) fromStorageAuthSession(s storage.AuthSession) AuthSession { AbsoluteExpiry: s.AbsoluteExpiry, IdleExpiry: s.IdleExpiry, LogoutState: s.LogoutState, + ConnectorData: s.ConnectorData, } } @@ -1068,6 +1070,7 @@ func toStorageAuthSession(s AuthSession) storage.AuthSession { AbsoluteExpiry: s.AbsoluteExpiry, IdleExpiry: s.IdleExpiry, LogoutState: s.LogoutState, + ConnectorData: s.ConnectorData, } if result.ClientStates == nil { result.ClientStates = make(map[string]*storage.ClientAuthState) diff --git a/storage/sql/crud.go b/storage/sql/crud.go index a8eaf2fd..5ef6dddf 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -998,15 +998,17 @@ func (c *conn) CreateAuthSession(ctx context.Context, s storage.AuthSession) err created_at, last_activity, ip_address, user_agent, absolute_expiry, idle_expiry, + connector_data, logout_state ) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11); + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12); `, s.UserID, s.ConnectorID, s.Nonce, encoder(s.ClientStates), s.CreatedAt, s.LastActivity, s.IPAddress, s.UserAgent, s.AbsoluteExpiry, s.IdleExpiry, + s.ConnectorData, encoder(s.LogoutState), ) if err != nil { @@ -1036,12 +1038,14 @@ func (c *conn) UpdateAuthSession(ctx context.Context, userID, connectorID string last_activity = $2, ip_address = $3, user_agent = $4, - logout_state = $5 - where user_id = $6 AND connector_id = $7; + connector_data = $5, + logout_state = $6 + where user_id = $7 AND connector_id = $8; `, encoder(newSession.ClientStates), newSession.LastActivity, newSession.IPAddress, newSession.UserAgent, + newSession.ConnectorData, encoder(newSession.LogoutState), userID, connectorID, ) @@ -1062,6 +1066,7 @@ const authSessionColumns = ` created_at, last_activity, ip_address, user_agent, absolute_expiry, idle_expiry, + connector_data, logout_state ` @@ -1081,6 +1086,7 @@ func scanAuthSession(s scanner) (session storage.AuthSession, err error) { &session.CreatedAt, &session.LastActivity, &session.IPAddress, &session.UserAgent, &session.AbsoluteExpiry, &session.IdleExpiry, + &session.ConnectorData, &logoutState, ) if err != nil { diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index d9b3dbed..caa488ac 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -467,4 +467,10 @@ var migrations = []migration{ add column sso_shared_with bytea;`, }, }, + { + stmts: []string{ + `alter table auth_session + add column connector_data bytea;`, + }, + }, } diff --git a/storage/storage.go b/storage/storage.go index 6d5f40b4..2a99a9bf 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -455,6 +455,10 @@ type AuthSession struct { // upstream provider. The callback handler reads it back to complete the flow. // Nil when no logout is in progress. LogoutState *LogoutState + + // Connector data is set during login, meant to store information from the + // upstream OIDC connector to be used later on logout (id_token) + ConnectorData []byte } // OfflineSessions objects are sessions pertaining to users with refresh tokens.