mirror of
https://github.com/netbirdio/dex.git
synced 2026-05-22 18:43:53 -07:00
Merge pull request #3 from netbirdio/fix/implement-id-token-hint-rp-logout
Fix/implement id token hint rp logout
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-1
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+12
-3
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user