fix(auth): build OIDC end-session URL with RP-Initiated Logout params (#2943)
This commit is contained in:
parent
54fbc79a52
commit
7208694960
|
|
@ -533,9 +533,11 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
|
|
||||||
// Revoke the server session so the refresh token can't be reused.
|
// Revoke the server session so the refresh token can't be reused.
|
||||||
// Best-effort: if the network call fails, still clean up locally.
|
// Best-effort: if the network call fails, still clean up locally.
|
||||||
|
let oidcLogoutUrl = ''
|
||||||
try {
|
try {
|
||||||
const HTTP = AuthenticatedHTTPFactory()
|
const HTTP = AuthenticatedHTTPFactory()
|
||||||
await HTTP.post('user/logout')
|
const {data} = await HTTP.post('user/logout')
|
||||||
|
oidcLogoutUrl = data?.oidc_logout_url ?? ''
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// Ignore — session will expire naturally
|
// Ignore — session will expire naturally
|
||||||
}
|
}
|
||||||
|
|
@ -547,7 +549,12 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
await router.push({name: 'user.login'})
|
await router.push({name: 'user.login'})
|
||||||
await checkAuth()
|
await checkAuth()
|
||||||
|
|
||||||
// if configured, redirect to OIDC Provider on logout
|
// Redirect to the OIDC provider to end its session too. Prefer the
|
||||||
|
// server-built RP-Initiated Logout URL, falling back to the static one.
|
||||||
|
if (oidcLogoutUrl) {
|
||||||
|
window.location.href = oidcLogoutUrl
|
||||||
|
return
|
||||||
|
}
|
||||||
const fullProvider: IProvider|undefined = configStore.auth.openidConnect.providers?.find((p: IProvider) => p.key === loggedInVia)
|
const fullProvider: IProvider|undefined = configStore.auth.openidConnect.providers?.find((p: IProvider) => p.key === loggedInVia)
|
||||||
if (fullProvider) {
|
if (fullProvider) {
|
||||||
redirectToProviderOnLogout(fullProvider)
|
redirectToProviderOnLogout(fullProvider)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"src.techknowlogick.com/xormigrate"
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mirrors models.Session; adds the two columns RP-Initiated Logout needs.
|
||||||
|
type sessionOIDCLogout20260619155410 struct {
|
||||||
|
ID string `xorm:"varchar(36) not null unique pk"`
|
||||||
|
UserID int64 `xorm:"bigint not null index"`
|
||||||
|
TokenHash string `xorm:"varchar(64) not null unique index"`
|
||||||
|
DeviceInfo string `xorm:"text"`
|
||||||
|
IPAddress string `xorm:"varchar(100)"`
|
||||||
|
IsLongSession bool `xorm:"not null default false"`
|
||||||
|
OIDCIDToken string `xorm:"text"`
|
||||||
|
OIDCProviderKey string `xorm:"varchar(250)"`
|
||||||
|
LastActive time.Time `xorm:"not null"`
|
||||||
|
Created time.Time `xorm:"created not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sessionOIDCLogout20260619155410) TableName() string {
|
||||||
|
return "sessions"
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
migrations = append(migrations, &xormigrate.Migration{
|
||||||
|
ID: "20260619155410",
|
||||||
|
Description: "Add oidc_id_token and oidc_provider_key columns to sessions for RP-Initiated Logout",
|
||||||
|
Migrate: func(tx *xorm.Engine) error {
|
||||||
|
return tx.Sync(sessionOIDCLogout20260619155410{})
|
||||||
|
},
|
||||||
|
Rollback: func(tx *xorm.Engine) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,10 @@ type Session struct {
|
||||||
IPAddress string `xorm:"varchar(100)" json:"ip_address" readOnly:"true" doc:"IP address captured from the login request."`
|
IPAddress string `xorm:"varchar(100)" json:"ip_address" readOnly:"true" doc:"IP address captured from the login request."`
|
||||||
// Whether this is a "remember me" session (controls max refresh lifetime).
|
// Whether this is a "remember me" session (controls max refresh lifetime).
|
||||||
IsLongSession bool `xorm:"not null default false" json:"-"`
|
IsLongSession bool `xorm:"not null default false" json:"-"`
|
||||||
|
// Raw OIDC ID token, kept so logout can replay it as id_token_hint. Empty for non-OIDC sessions.
|
||||||
|
OIDCIDToken string `xorm:"text" json:"-"`
|
||||||
|
// OIDC provider that created this session, used to find its end-session endpoint at logout.
|
||||||
|
OIDCProviderKey string `xorm:"varchar(250)" json:"-"`
|
||||||
// When this session was last refreshed.
|
// When this session was last refreshed.
|
||||||
LastActive time.Time `xorm:"not null" json:"last_active" readOnly:"true" doc:"When this session was last refreshed."`
|
LastActive time.Time `xorm:"not null" json:"last_active" readOnly:"true" doc:"When this session was last refreshed."`
|
||||||
// When this session was created (login time).
|
// When this session was created (login time).
|
||||||
|
|
@ -81,9 +85,17 @@ func generateHashedToken() (rawToken, hash string, err error) {
|
||||||
return rawToken, HashSessionToken(rawToken), nil
|
return rawToken, HashSessionToken(rawToken), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SessionOIDCData carries the OIDC metadata persisted on a session so an
|
||||||
|
// RP-Initiated Logout request can be built later. Nil for non-OIDC logins.
|
||||||
|
type SessionOIDCData struct {
|
||||||
|
IDToken string
|
||||||
|
ProviderKey string
|
||||||
|
}
|
||||||
|
|
||||||
// CreateSession creates a new session record and generates a refresh token.
|
// CreateSession creates a new session record and generates a refresh token.
|
||||||
// Returns the session with RefreshToken populated (cleartext, shown only once).
|
// Returns the session with RefreshToken populated (cleartext, shown only once).
|
||||||
func CreateSession(s *xorm.Session, userID int64, deviceInfo, ipAddress string, isLongSession bool) (*Session, error) {
|
// Pass oidc for OpenID Connect logins to persist the logout data; nil otherwise.
|
||||||
|
func CreateSession(s *xorm.Session, userID int64, deviceInfo, ipAddress string, isLongSession bool, oidc *SessionOIDCData) (*Session, error) {
|
||||||
rawToken, hash, err := generateHashedToken()
|
rawToken, hash, err := generateHashedToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -98,6 +110,10 @@ func CreateSession(s *xorm.Session, userID int64, deviceInfo, ipAddress string,
|
||||||
IsLongSession: isLongSession,
|
IsLongSession: isLongSession,
|
||||||
LastActive: time.Now(),
|
LastActive: time.Now(),
|
||||||
}
|
}
|
||||||
|
if oidc != nil {
|
||||||
|
session.OIDCIDToken = oidc.IDToken
|
||||||
|
session.OIDCProviderKey = oidc.ProviderKey
|
||||||
|
}
|
||||||
|
|
||||||
_, err = s.Insert(session)
|
_, err = s.Insert(session)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -112,12 +112,13 @@ type IssuedUserToken struct {
|
||||||
// IssueUserToken creates a session for the user and mints a JWT access token plus
|
// IssueUserToken creates a session for the user and mints a JWT access token plus
|
||||||
// a refresh token for it. It is the transport-agnostic core both v1 (which writes
|
// a refresh token for it. It is the transport-agnostic core both v1 (which writes
|
||||||
// the echo response) and v2 (Huma) call; callers set the refresh cookie and the
|
// the echo response) and v2 (Huma) call; callers set the refresh cookie and the
|
||||||
// Cache-Control header themselves via WriteUserAuthCookies.
|
// Cache-Control header themselves via WriteUserAuthCookies. Pass oidc for
|
||||||
func IssueUserToken(ctx context.Context, u *user.User, deviceInfo, ipAddress string, long bool) (*IssuedUserToken, error) {
|
// OpenID Connect logins to store the logout data; nil otherwise.
|
||||||
|
func IssueUserToken(ctx context.Context, u *user.User, deviceInfo, ipAddress string, long bool, oidc *models.SessionOIDCData) (*IssuedUserToken, error) {
|
||||||
s := db.NewSession()
|
s := db.NewSession()
|
||||||
defer s.Close()
|
defer s.Close()
|
||||||
|
|
||||||
session, err := models.CreateSession(s, u.ID, deviceInfo, ipAddress, long)
|
session, err := models.CreateSession(s, u.ID, deviceInfo, ipAddress, long, oidc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = s.Rollback()
|
_ = s.Rollback()
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -161,8 +162,9 @@ func WriteUserAuthCookies(c *echo.Context, token *IssuedUserToken) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserAuthTokenResponse creates a new user auth token response from a user object.
|
// NewUserAuthTokenResponse creates a new user auth token response from a user object.
|
||||||
func NewUserAuthTokenResponse(u *user.User, c *echo.Context, long bool) error {
|
// Pass oidc for OpenID Connect logins to store the logout data; nil otherwise.
|
||||||
token, err := IssueUserToken(c.Request().Context(), u, c.Request().UserAgent(), c.RealIP(), long)
|
func NewUserAuthTokenResponse(u *user.User, c *echo.Context, long bool, oidc *models.SessionOIDCData) error {
|
||||||
|
token, err := IssueUserToken(c.Request().Context(), u, c.Request().UserAgent(), c.RealIP(), long, oidc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ func exchangeAuthorizationCode(ctx context.Context, req *TokenRequest, deviceInf
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a session (reuses existing session infrastructure)
|
// Create a session (reuses existing session infrastructure)
|
||||||
session, err := models.CreateSession(s, oauthCode.UserID, deviceInfo, ipAddress, false)
|
session, err := models.CreateSession(s, oauthCode.UserID, deviceInfo, ipAddress, false, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = s.Rollback()
|
_ = s.Rollback()
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package openid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"code.vikunja.io/api/pkg/config"
|
||||||
|
"code.vikunja.io/api/pkg/log"
|
||||||
|
"code.vikunja.io/api/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EndSessionEndpoint returns the provider's RP-Initiated Logout endpoint
|
||||||
|
// (discovery's end_session_endpoint, cached at init), falling back to the static
|
||||||
|
// logouturl. Never triggers discovery so logout stays responsive when the OP is
|
||||||
|
// unreachable.
|
||||||
|
func (p *Provider) EndSessionEndpoint() string {
|
||||||
|
if p.EndSessionURL != "" {
|
||||||
|
return p.EndSessionURL
|
||||||
|
}
|
||||||
|
return p.LogoutURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoveredEndSessionEndpoint reads end_session_endpoint from the discovery
|
||||||
|
// document already cached on the *oidc.Provider, so Claims unmarshals in memory
|
||||||
|
// without a request.
|
||||||
|
func (p *Provider) discoveredEndSessionEndpoint() string {
|
||||||
|
if p.openIDProvider == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta struct {
|
||||||
|
EndSessionEndpoint string `json:"end_session_endpoint"`
|
||||||
|
}
|
||||||
|
if err := p.openIDProvider.Claims(&meta); err != nil {
|
||||||
|
log.Debugf("Could not read end_session_endpoint for provider %s: %v", p.Key, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return meta.EndSessionEndpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildEndSessionURL builds an OpenID Connect RP-Initiated Logout 1.0 request URL
|
||||||
|
// (id_token_hint + post_logout_redirect_uri + client_id; see RP-Initiated Logout
|
||||||
|
// 1.0 §2). post_logout_redirect_uri defaults to service.publicurl, and the OP
|
||||||
|
// only honors it when id_token_hint is present. Returns "" when neither an
|
||||||
|
// end_session_endpoint nor a static logouturl is configured.
|
||||||
|
func BuildEndSessionURL(providerKey string, oidc *models.SessionOIDCData) (string, error) {
|
||||||
|
// GetProvider would trigger OIDC discovery (a live HTTP GET that blocks when
|
||||||
|
// the OP is down); the cached static fields are all logout needs.
|
||||||
|
provider, err := getCachedProvider(providerKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if provider == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken := ""
|
||||||
|
if oidc != nil {
|
||||||
|
idToken = oidc.IDToken
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildEndSessionURL(
|
||||||
|
provider.EndSessionEndpoint(),
|
||||||
|
provider.ClientID,
|
||||||
|
idToken,
|
||||||
|
config.ServicePublicURL.GetString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEndSessionURL appends the logout query params onto endpoint, omitting
|
||||||
|
// empty ones, and returns "" for an empty endpoint.
|
||||||
|
func buildEndSessionURL(endpoint, clientID, idToken, postLogoutRedirectURI string) (string, error) {
|
||||||
|
if endpoint == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
q := u.Query()
|
||||||
|
if clientID != "" {
|
||||||
|
q.Set("client_id", clientID)
|
||||||
|
}
|
||||||
|
if idToken != "" {
|
||||||
|
q.Set("id_token_hint", idToken)
|
||||||
|
}
|
||||||
|
if postLogoutRedirectURI != "" {
|
||||||
|
q.Set("post_logout_redirect_uri", postLogoutRedirectURI)
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package openid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.vikunja.io/api/pkg/config"
|
||||||
|
"code.vikunja.io/api/pkg/models"
|
||||||
|
"code.vikunja.io/api/pkg/modules/keyvalue"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newMockOIDCServerWithEndSession publishes a discovery document with an
|
||||||
|
// end_session_endpoint.
|
||||||
|
func newMockOIDCServerWithEndSession() *httptest.Server {
|
||||||
|
var server *httptest.Server
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
discovery := map[string]interface{}{
|
||||||
|
"issuer": server.URL,
|
||||||
|
"authorization_endpoint": server.URL + "/auth",
|
||||||
|
"token_endpoint": server.URL + "/token",
|
||||||
|
"jwks_uri": server.URL + "/jwks",
|
||||||
|
"end_session_endpoint": server.URL + "/logout",
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(discovery)
|
||||||
|
})
|
||||||
|
server = httptest.NewServer(mux)
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildEndSessionURLAssembly(t *testing.T) {
|
||||||
|
t.Run("all params", func(t *testing.T) {
|
||||||
|
got, err := buildEndSessionURL("https://op.example.com/logout", "my-client", "the-id-token", "https://vikunja.example.com/")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
u, err := url.Parse(got)
|
||||||
|
require.NoError(t, err)
|
||||||
|
q := u.Query()
|
||||||
|
assert.Equal(t, "https", u.Scheme)
|
||||||
|
assert.Equal(t, "op.example.com", u.Host)
|
||||||
|
assert.Equal(t, "/logout", u.Path)
|
||||||
|
assert.Equal(t, "the-id-token", q.Get("id_token_hint"))
|
||||||
|
assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri"))
|
||||||
|
assert.Equal(t, "my-client", q.Get("client_id"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("preserves existing endpoint query params", func(t *testing.T) {
|
||||||
|
got, err := buildEndSessionURL("https://op.example.com/logout?foo=bar", "my-client", "the-id-token", "https://vikunja.example.com/")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
u, err := url.Parse(got)
|
||||||
|
require.NoError(t, err)
|
||||||
|
q := u.Query()
|
||||||
|
assert.Equal(t, "bar", q.Get("foo"))
|
||||||
|
assert.Equal(t, "the-id-token", q.Get("id_token_hint"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("omits id_token_hint when no token", func(t *testing.T) {
|
||||||
|
got, err := buildEndSessionURL("https://op.example.com/logout", "my-client", "", "https://vikunja.example.com/")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
u, err := url.Parse(got)
|
||||||
|
require.NoError(t, err)
|
||||||
|
q := u.Query()
|
||||||
|
assert.False(t, q.Has("id_token_hint"))
|
||||||
|
assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri"))
|
||||||
|
assert.Equal(t, "my-client", q.Get("client_id"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty endpoint returns empty", func(t *testing.T) {
|
||||||
|
got, err := buildEndSessionURL("", "my-client", "the-id-token", "https://vikunja.example.com/")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildEndSessionURLFromDiscovery(t *testing.T) {
|
||||||
|
defer CleanupSavedOpenIDProviders()
|
||||||
|
|
||||||
|
server := newMockOIDCServerWithEndSession()
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
config.AuthOpenIDEnabled.Set(true)
|
||||||
|
config.ServicePublicURL.Set("https://vikunja.example.com/")
|
||||||
|
config.AuthOpenIDProviders.Set(map[string]interface{}{
|
||||||
|
"provider1": map[string]interface{}{
|
||||||
|
"name": "Provider One",
|
||||||
|
"authurl": server.URL,
|
||||||
|
"clientid": "client1",
|
||||||
|
"clientsecret": "secret1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
_ = keyvalue.Del("openid_providers")
|
||||||
|
_ = keyvalue.Del("openid_provider_provider1")
|
||||||
|
|
||||||
|
got, err := BuildEndSessionURL("provider1", &models.SessionOIDCData{
|
||||||
|
IDToken: "raw-id-token",
|
||||||
|
ProviderKey: "provider1",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
u, err := url.Parse(got)
|
||||||
|
require.NoError(t, err)
|
||||||
|
q := u.Query()
|
||||||
|
assert.Equal(t, server.URL+"/logout", u.Scheme+"://"+u.Host+u.Path)
|
||||||
|
assert.Equal(t, "raw-id-token", q.Get("id_token_hint"))
|
||||||
|
assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri"))
|
||||||
|
assert.Equal(t, "client1", q.Get("client_id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildEndSessionURLFromCachedProviderWithoutLiveObject(t *testing.T) {
|
||||||
|
defer CleanupSavedOpenIDProviders()
|
||||||
|
|
||||||
|
config.AuthOpenIDEnabled.Set(true)
|
||||||
|
config.ServicePublicURL.Set("https://vikunja.example.com/")
|
||||||
|
|
||||||
|
// Seed only the cached static fields (no live openIDProvider), mimicking a
|
||||||
|
// provider restored from keyvalue whose OP is unreachable.
|
||||||
|
_ = keyvalue.Del("openid_providers")
|
||||||
|
require.NoError(t, keyvalue.Put("openid_provider_provider1", &Provider{
|
||||||
|
Key: "provider1",
|
||||||
|
ClientID: "client1",
|
||||||
|
EndSessionURL: "https://op.example.com/end-session",
|
||||||
|
}))
|
||||||
|
|
||||||
|
got, err := BuildEndSessionURL("provider1", &models.SessionOIDCData{
|
||||||
|
IDToken: "raw-id-token",
|
||||||
|
ProviderKey: "provider1",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
u, err := url.Parse(got)
|
||||||
|
require.NoError(t, err)
|
||||||
|
q := u.Query()
|
||||||
|
assert.Equal(t, "https://op.example.com/end-session", u.Scheme+"://"+u.Host+u.Path)
|
||||||
|
assert.Equal(t, "raw-id-token", q.Get("id_token_hint"))
|
||||||
|
assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri"))
|
||||||
|
assert.Equal(t, "client1", q.Get("client_id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndSessionEndpointUsesCachedURLWithoutDiscovery(t *testing.T) {
|
||||||
|
// A nil openIDProvider models a provider restored from cache (or an
|
||||||
|
// unreachable OP): EndSessionEndpoint must answer from the cached URL.
|
||||||
|
p := &Provider{
|
||||||
|
Key: "provider1",
|
||||||
|
LogoutURL: "https://op.example.com/static-logout",
|
||||||
|
EndSessionURL: "https://op.example.com/end-session",
|
||||||
|
}
|
||||||
|
assert.Equal(t, "https://op.example.com/end-session", p.EndSessionEndpoint())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndSessionEndpointFallsBackToLogoutURLWhenNotCached(t *testing.T) {
|
||||||
|
p := &Provider{
|
||||||
|
Key: "provider1",
|
||||||
|
LogoutURL: "https://op.example.com/static-logout",
|
||||||
|
}
|
||||||
|
assert.Equal(t, "https://op.example.com/static-logout", p.EndSessionEndpoint())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndSessionEndpointCachedFromDiscoveryOnInit(t *testing.T) {
|
||||||
|
defer CleanupSavedOpenIDProviders()
|
||||||
|
|
||||||
|
server := newMockOIDCServerWithEndSession()
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
config.AuthOpenIDEnabled.Set(true)
|
||||||
|
config.AuthOpenIDProviders.Set(map[string]interface{}{
|
||||||
|
"provider1": map[string]interface{}{
|
||||||
|
"name": "Provider One",
|
||||||
|
"authurl": server.URL,
|
||||||
|
"clientid": "client1",
|
||||||
|
"clientsecret": "secret1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
_ = keyvalue.Del("openid_providers")
|
||||||
|
_ = keyvalue.Del("openid_provider_provider1")
|
||||||
|
|
||||||
|
provider, err := GetProvider("provider1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, provider)
|
||||||
|
|
||||||
|
assert.Equal(t, server.URL+"/logout", provider.EndSessionURL)
|
||||||
|
assert.Equal(t, server.URL+"/logout", provider.EndSessionEndpoint())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndSessionEndpointFallsBackToStaticLogoutURL(t *testing.T) {
|
||||||
|
defer CleanupSavedOpenIDProviders()
|
||||||
|
|
||||||
|
// newMockOIDCServer publishes no end_session_endpoint, forcing the logouturl fallback.
|
||||||
|
server := newMockOIDCServer()
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
config.AuthOpenIDEnabled.Set(true)
|
||||||
|
config.AuthOpenIDProviders.Set(map[string]interface{}{
|
||||||
|
"provider1": map[string]interface{}{
|
||||||
|
"name": "Provider One",
|
||||||
|
"authurl": server.URL,
|
||||||
|
"clientid": "client1",
|
||||||
|
"clientsecret": "secret1",
|
||||||
|
"logouturl": "https://op.example.com/static-logout",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
_ = keyvalue.Del("openid_providers")
|
||||||
|
_ = keyvalue.Del("openid_provider_provider1")
|
||||||
|
|
||||||
|
provider, err := GetProvider("provider1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, provider)
|
||||||
|
|
||||||
|
assert.Equal(t, "https://op.example.com/static-logout", provider.EndSessionEndpoint())
|
||||||
|
}
|
||||||
|
|
@ -69,6 +69,10 @@ type Provider struct {
|
||||||
ForceUserInfo bool `json:"force_user_info"`
|
ForceUserInfo bool `json:"force_user_info"`
|
||||||
RequireAvailability bool `json:"-"`
|
RequireAvailability bool `json:"-"`
|
||||||
ClientSecret string `json:"-"`
|
ClientSecret string `json:"-"`
|
||||||
|
// RP-Initiated Logout endpoint, cached at init so logout never fetches.
|
||||||
|
// Exported so it survives the gob keyvalue round-trip (gob skips unexported
|
||||||
|
// fields like openIDProvider); json:"-" keeps it out of /info.
|
||||||
|
EndSessionURL string `json:"-"`
|
||||||
openIDProvider *oidc.Provider
|
openIDProvider *oidc.Provider
|
||||||
Oauth2Config *oauth2.Config `json:"-"`
|
Oauth2Config *oauth2.Config `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +177,7 @@ func HandleCallback(c *echo.Context) error {
|
||||||
return &models.ErrOpenIDBadRequest{Message: "Bad data"}
|
return &models.ErrOpenIDBadRequest{Message: "Bad data"}
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := AuthenticateCallback(c.Request().Context(), cb, c.Param("provider"))
|
u, oidcData, err := AuthenticateCallback(c.Request().Context(), cb, c.Param("provider"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var detailedErr *models.ErrOpenIDBadRequestWithDetails
|
var detailedErr *models.ErrOpenIDBadRequestWithDetails
|
||||||
if errors.As(err, &detailedErr) {
|
if errors.As(err, &detailedErr) {
|
||||||
|
|
@ -186,7 +190,7 @@ func HandleCallback(c *echo.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create token
|
// Create token
|
||||||
return auth.NewUserAuthTokenResponse(u, c, false)
|
return auth.NewUserAuthTokenResponse(u, c, false, oidcData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthenticateCallback resolves an OpenID Connect callback to an authenticated
|
// AuthenticateCallback resolves an OpenID Connect callback to an authenticated
|
||||||
|
|
@ -196,18 +200,24 @@ func HandleCallback(c *echo.Context) error {
|
||||||
// handler and the v2 Huma handler; the caller issues the auth token. The
|
// handler and the v2 Huma handler; the caller issues the auth token. The
|
||||||
// ErrOpenIDBadRequestWithDetails error keeps its provider detail so v1 can render
|
// ErrOpenIDBadRequestWithDetails error keeps its provider detail so v1 can render
|
||||||
// its bespoke body and v2 can map it to RFC 9457.
|
// its bespoke body and v2 can map it to RFC 9457.
|
||||||
func AuthenticateCallback(ctx context.Context, cb *Callback, providerKey string) (*user.User, error) {
|
func AuthenticateCallback(ctx context.Context, cb *Callback, providerKey string) (*user.User, *models.SessionOIDCData, error) {
|
||||||
// ctx is threaded through only to dispatch the login event; the OIDC token
|
// ctx is threaded through only to dispatch the login event; the OIDC token
|
||||||
// exchange, claim verification and user/avatar sync run on their own
|
// exchange, claim verification and user/avatar sync run on their own
|
||||||
// background contexts, exactly as the v1 callback always did.
|
// background contexts, exactly as the v1 callback always did.
|
||||||
provider, oauthToken, idToken, err := exchangeOidcTokens(cb, providerKey) //nolint:contextcheck
|
provider, oauthToken, idToken, rawIDToken, err := exchangeOidcTokens(cb, providerKey) //nolint:contextcheck
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stored so logout can replay it as id_token_hint in an RP-Initiated Logout.
|
||||||
|
oidcData := &models.SessionOIDCData{
|
||||||
|
IDToken: rawIDToken,
|
||||||
|
ProviderKey: providerKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
cl, err := getClaims(provider, oauthToken, idToken) //nolint:contextcheck
|
cl, err := getClaims(provider, oauthToken, idToken) //nolint:contextcheck
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s := db.NewSession()
|
s := db.NewSession()
|
||||||
|
|
@ -221,16 +231,16 @@ func AuthenticateCallback(ctx context.Context, cb *Callback, providerKey string)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = s.Rollback()
|
_ = s.Rollback()
|
||||||
log.Errorf("Error creating new user for provider %s: %v", provider.Name, err)
|
log.Errorf("Error creating new user for provider %s: %v", provider.Name, err)
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.Status == user.StatusDisabled {
|
if u.Status == user.StatusDisabled {
|
||||||
_ = s.Rollback()
|
_ = s.Rollback()
|
||||||
return nil, &user.ErrAccountDisabled{UserID: u.ID}
|
return nil, nil, &user.ErrAccountDisabled{UserID: u.ID}
|
||||||
}
|
}
|
||||||
if u.Status == user.StatusAccountLocked {
|
if u.Status == user.StatusAccountLocked {
|
||||||
_ = s.Rollback()
|
_ = s.Rollback()
|
||||||
return nil, &user.ErrAccountLocked{UserID: u.ID}
|
return nil, nil, &user.ErrAccountLocked{UserID: u.ID}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must run before team sync so a failed 2FA attempt cannot mutate team
|
// Must run before team sync so a failed 2FA attempt cannot mutate team
|
||||||
|
|
@ -247,26 +257,26 @@ func AuthenticateCallback(ctx context.Context, cb *Callback, providerKey string)
|
||||||
if user.IsErrInvalidTOTPPasscode(err) {
|
if user.IsErrInvalidTOTPPasscode(err) {
|
||||||
user.HandleFailedTOTPAuth(u)
|
user.HandleFailedTOTPAuth(u)
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
teamData := getTeamDataFromToken(cl.VikunjaGroups, provider)
|
teamData := getTeamDataFromToken(cl.VikunjaGroups, provider)
|
||||||
|
|
||||||
err = models.SyncExternalTeamsForUser(s, u, teamData, idToken.Issuer, provider.Name)
|
err = models.SyncExternalTeamsForUser(s, u, teamData, idToken.Issuer, provider.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.Commit()
|
err = s.Commit()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = s.Rollback()
|
_ = s.Rollback()
|
||||||
log.Errorf("Error creating new team for provider %s: %v", provider.Name, err)
|
log.Errorf("Error creating new team for provider %s: %v", provider.Name, err)
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
events.DispatchPending(ctx, s)
|
events.DispatchPending(ctx, s)
|
||||||
|
|
||||||
return u, nil
|
return u, oidcData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTeamDataFromToken(groups []map[string]interface{}, provider *Provider) (teamData []*models.Team) {
|
func getTeamDataFromToken(groups []map[string]interface{}, provider *Provider) (teamData []*models.Team) {
|
||||||
|
|
@ -543,13 +553,13 @@ func getClaims(provider *Provider, oauth2Token *oauth2.Token, idToken *oidc.IDTo
|
||||||
// and verifies the returned ID token. It takes an already-bound Callback so it
|
// and verifies the returned ID token. It takes an already-bound Callback so it
|
||||||
// can be shared by the v1 echo handler (which binds from the request) and the v2
|
// can be shared by the v1 echo handler (which binds from the request) and the v2
|
||||||
// Huma handler (which binds via its typed body).
|
// Huma handler (which binds via its typed body).
|
||||||
func exchangeOidcTokens(cb *Callback, providerKey string) (*Provider, *oauth2.Token, *oidc.IDToken, error) {
|
func exchangeOidcTokens(cb *Callback, providerKey string) (*Provider, *oauth2.Token, *oidc.IDToken, string, error) {
|
||||||
provider, err := GetProvider(providerKey)
|
provider, err := GetProvider(providerKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, "", err
|
||||||
}
|
}
|
||||||
if provider == nil {
|
if provider == nil {
|
||||||
return nil, nil, nil, &models.ErrOpenIDBadRequest{Message: "Provider does not exist"}
|
return nil, nil, nil, "", &models.ErrOpenIDBadRequest{Message: "Provider does not exist"}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debugf("Trying to authenticate user using provider: %s", provider.Key)
|
log.Debugf("Trying to authenticate user using provider: %s", provider.Key)
|
||||||
|
|
@ -565,25 +575,25 @@ func exchangeOidcTokens(cb *Callback, providerKey string) (*Provider, *oauth2.To
|
||||||
if err := json.Unmarshal(rerr.Body, &details); err != nil {
|
if err := json.Unmarshal(rerr.Body, &details); err != nil {
|
||||||
log.Errorf("Error unmarshalling token for provider %s: %v", provider.Name, err)
|
log.Errorf("Error unmarshalling token for provider %s: %v", provider.Name, err)
|
||||||
log.Debugf("Raw token value is %s", rerr.Body)
|
log.Debugf("Raw token value is %s", rerr.Body)
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Errorf("Error retrieving token: %s", err)
|
log.Errorf("Error retrieving token: %s", err)
|
||||||
log.Debugf("Raw token value is %s", rerr.Body)
|
log.Debugf("Raw token value is %s", rerr.Body)
|
||||||
return nil, nil, nil, &models.ErrOpenIDBadRequestWithDetails{
|
return nil, nil, nil, "", &models.ErrOpenIDBadRequestWithDetails{
|
||||||
Message: "Could not authenticate against third party.",
|
Message: "Could not authenticate against third party.",
|
||||||
Details: details,
|
Details: details,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the ID Token from OAuth2 token.
|
// Extract the ID Token from OAuth2 token.
|
||||||
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Debugf("Could not get id_token, raw token is %v", oauth2Token)
|
log.Debugf("Could not get id_token, raw token is %v", oauth2Token)
|
||||||
return nil, nil, nil, &models.ErrOpenIDBadRequest{Message: "Missing token"}
|
return nil, nil, nil, "", &models.ErrOpenIDBadRequest{Message: "Missing token"}
|
||||||
}
|
}
|
||||||
|
|
||||||
verifier := provider.openIDProvider.Verifier(&oidc.Config{ClientID: provider.ClientID})
|
verifier := provider.openIDProvider.Verifier(&oidc.Config{ClientID: provider.ClientID})
|
||||||
|
|
@ -592,8 +602,8 @@ func exchangeOidcTokens(cb *Callback, providerKey string) (*Provider, *oauth2.To
|
||||||
idToken, err := verifier.Verify(context.Background(), rawIDToken)
|
idToken, err := verifier.Verify(context.Background(), rawIDToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Error verifying token for provider %s: %v", provider.Name, err)
|
log.Errorf("Error verifying token for provider %s: %v", provider.Name, err)
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return provider, oauth2Token, idToken, nil
|
return provider, oauth2Token, idToken, rawIDToken, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,29 @@ func GetProvider(key string) (provider *Provider, err error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getCachedProvider returns the provider from keyvalue without re-establishing
|
||||||
|
// the live OIDC connection, so the logout path never blocks on an unreachable OP.
|
||||||
|
func getCachedProvider(key string) (provider *Provider, err error) {
|
||||||
|
provider = &Provider{}
|
||||||
|
exists, err := keyvalue.GetWithValue("openid_provider_"+key, provider)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
_, err = GetAllProviders() // This will put all providers in cache
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = keyvalue.GetWithValue("openid_provider_"+key, provider)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider, nil
|
||||||
|
}
|
||||||
|
|
||||||
// parseBoolField reads a boolean-valued config field from a provider map,
|
// parseBoolField reads a boolean-valued config field from a provider map,
|
||||||
// tolerating both native bools (from YAML/JSON) and strings (from env vars or
|
// tolerating both native bools (from YAML/JSON) and strings (from env vars or
|
||||||
// the GetConfigValueFromFile path, which always return strings). Missing or
|
// the GetConfigValueFromFile path, which always return strings). Missing or
|
||||||
|
|
@ -313,6 +336,8 @@ func getProviderFromMap(pi map[string]interface{}, key string) (provider *Provid
|
||||||
|
|
||||||
provider.AuthURL = provider.Oauth2Config.Endpoint.AuthURL
|
provider.AuthURL = provider.Oauth2Config.Endpoint.AuthURL
|
||||||
|
|
||||||
|
provider.EndSessionURL = provider.discoveredEndSessionEndpoint()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import (
|
||||||
"code.vikunja.io/api/pkg/models"
|
"code.vikunja.io/api/pkg/models"
|
||||||
"code.vikunja.io/api/pkg/modules/auth"
|
"code.vikunja.io/api/pkg/modules/auth"
|
||||||
"code.vikunja.io/api/pkg/modules/auth/ldap"
|
"code.vikunja.io/api/pkg/modules/auth/ldap"
|
||||||
|
"code.vikunja.io/api/pkg/modules/auth/openid"
|
||||||
"code.vikunja.io/api/pkg/modules/keyvalue"
|
"code.vikunja.io/api/pkg/modules/keyvalue"
|
||||||
"code.vikunja.io/api/pkg/user"
|
"code.vikunja.io/api/pkg/user"
|
||||||
|
|
||||||
|
|
@ -192,24 +193,53 @@ func enforceLoginTOTP(s *xorm.Session, u *user.User, passcode string) error {
|
||||||
// API token or a link share), matching v1. Shared by v1 and v2; the caller is
|
// API token or a link share), matching v1. Shared by v1 and v2; the caller is
|
||||||
// responsible for clearing the refresh cookie.
|
// responsible for clearing the refresh cookie.
|
||||||
func DeleteSession(sid string) error {
|
func DeleteSession(sid string) error {
|
||||||
|
_, err := LogoutSession(sid)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogoutSession deletes the session and returns its OIDC RP-Initiated Logout URL
|
||||||
|
// for the frontend to redirect to (empty for non-OIDC sessions or when no logout
|
||||||
|
// endpoint is configured). An empty sid is a no-op. The caller clears the refresh
|
||||||
|
// cookie.
|
||||||
|
func LogoutSession(sid string) (endSessionURL string, err error) {
|
||||||
if sid == "" {
|
if sid == "" {
|
||||||
return nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
s := db.NewSession()
|
s := db.NewSession()
|
||||||
defer s.Close()
|
defer s.Close()
|
||||||
|
|
||||||
|
// Read before deleting so the stored id_token survives for the logout URL.
|
||||||
|
// A missing session just means there is nothing to log out.
|
||||||
|
session, err := models.GetSessionByID(s, sid)
|
||||||
|
if err != nil && !models.IsErrSessionNotFound(err) {
|
||||||
|
_ = s.Rollback()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if session != nil && session.OIDCProviderKey != "" {
|
||||||
|
url, buildErr := openid.BuildEndSessionURL(session.OIDCProviderKey, &models.SessionOIDCData{
|
||||||
|
IDToken: session.OIDCIDToken,
|
||||||
|
ProviderKey: session.OIDCProviderKey,
|
||||||
|
})
|
||||||
|
if buildErr != nil {
|
||||||
|
// A failed URL build must not block logout; the session is still deleted below.
|
||||||
|
log.Errorf("Could not build OIDC end-session URL for session %s: %v", sid, buildErr)
|
||||||
|
} else {
|
||||||
|
endSessionURL = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := s.Where("id = ?", sid).Delete(&models.Session{}); err != nil {
|
if _, err := s.Where("id = ?", sid).Delete(&models.Session{}); err != nil {
|
||||||
_ = s.Rollback()
|
_ = s.Rollback()
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Commit(); err != nil {
|
if err := s.Commit(); err != nil {
|
||||||
_ = s.Rollback()
|
_ = s.Rollback()
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return endSessionURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetPassword resets a user's password from a previously issued reset token
|
// ResetPassword resets a user's password from a previously issued reset token
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ func Login(c *echo.Context) (err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create token
|
// Create token
|
||||||
return auth.NewUserAuthTokenResponse(user, c, u.LongToken)
|
return auth.NewUserAuthTokenResponse(user, c, u.LongToken, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenewToken renews a link share token only. User tokens must use
|
// RenewToken renews a link share token only. User tokens must use
|
||||||
|
|
@ -150,12 +150,18 @@ func RefreshToken(c *echo.Context) (err error) {
|
||||||
return c.JSON(http.StatusOK, auth.Token{Token: result.AccessToken})
|
return c.JSON(http.StatusOK, auth.Token{Token: result.AccessToken})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogoutResponse struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
// RP-Initiated Logout URL the frontend redirects to. Empty for non-OIDC sessions.
|
||||||
|
OIDCLogoutURL string `json:"oidc_logout_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// Logout deletes the current session from the server.
|
// Logout deletes the current session from the server.
|
||||||
// @Summary Logout
|
// @Summary Logout
|
||||||
// @Description Destroys the current session and clears the refresh token cookie.
|
// @Description Destroys the current session and clears the refresh token cookie. For OpenID Connect sessions the response includes an `oidc_logout_url` the client should redirect to so the provider session is ended too.
|
||||||
// @tags auth
|
// @tags auth
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} models.Message "Successfully logged out."
|
// @Success 200 {object} v1.LogoutResponse "Successfully logged out."
|
||||||
// @Router /user/logout [post]
|
// @Router /user/logout [post]
|
||||||
func Logout(c *echo.Context) (err error) {
|
func Logout(c *echo.Context) (err error) {
|
||||||
auth.ClearRefreshTokenCookie(c)
|
auth.ClearRefreshTokenCookie(c)
|
||||||
|
|
@ -177,7 +183,8 @@ func Logout(c *echo.Context) (err error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := shared.DeleteSession(sid); err != nil {
|
oidcLogoutURL, err := shared.LogoutSession(sid)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,5 +194,8 @@ func Logout(c *echo.Context) (err error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, models.Message{Message: "Successfully logged out."})
|
return c.JSON(http.StatusOK, LogoutResponse{
|
||||||
|
Message: "Successfully logged out.",
|
||||||
|
OIDCLogoutURL: oidcLogoutURL,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ type authTokenBody struct {
|
||||||
type logoutBody struct {
|
type logoutBody struct {
|
||||||
Body struct {
|
Body struct {
|
||||||
Message string `json:"message" readOnly:"true" doc:"A human-readable confirmation message."`
|
Message string `json:"message" readOnly:"true" doc:"A human-readable confirmation message."`
|
||||||
|
OIDCLogoutURL string `json:"oidc_logout_url,omitempty" readOnly:"true" doc:"RP-Initiated Logout URL to redirect to for OpenID Connect sessions; empty otherwise."`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,7 +87,7 @@ func authLogin(ctx context.Context, in *struct{ Body user.Login }) (*authTokenBo
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceInfo, ipAddress := requestClientInfo(ctx)
|
deviceInfo, ipAddress := requestClientInfo(ctx)
|
||||||
token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, in.Body.LongToken)
|
token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, in.Body.LongToken, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, translateDomainError(err)
|
return nil, translateDomainError(err)
|
||||||
}
|
}
|
||||||
|
|
@ -107,12 +108,14 @@ func authLogout(ctx context.Context, _ *struct{}) (*logoutBody, error) {
|
||||||
sid = auth.SessionIDFromContext(ec)
|
sid = auth.SessionIDFromContext(ec)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := shared.DeleteSession(sid); err != nil {
|
oidcLogoutURL, err := shared.LogoutSession(sid) //nolint:contextcheck // OIDC provider discovery resolves from a cached, context-less map and runs on its own background context, like the OIDC callback.
|
||||||
|
if err != nil {
|
||||||
return nil, translateDomainError(err)
|
return nil, translateDomainError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out := &logoutBody{}
|
out := &logoutBody{}
|
||||||
out.Body.Message = "Successfully logged out."
|
out.Body.Message = "Successfully logged out."
|
||||||
|
out.Body.OIDCLogoutURL = oidcLogoutURL
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,14 +55,14 @@ func authOpenIDCallback(ctx context.Context, in *struct {
|
||||||
Provider string `path:"provider" doc:"The OpenID Connect provider key as returned by the /info endpoint."`
|
Provider string `path:"provider" doc:"The OpenID Connect provider key as returned by the /info endpoint."`
|
||||||
Body openid.Callback `doc:"The provider callback, carrying the authorization code."`
|
Body openid.Callback `doc:"The provider callback, carrying the authorization code."`
|
||||||
}) (*authTokenBody, error) {
|
}) (*authTokenBody, error) {
|
||||||
u, err := openid.AuthenticateCallback(ctx, &in.Body, in.Provider) //nolint:contextcheck // resolves providers from a cached, context-less map and runs OIDC discovery on its own background context, like the v1 callback.
|
u, oidcData, err := openid.AuthenticateCallback(ctx, &in.Body, in.Provider) //nolint:contextcheck // resolves providers from a cached, context-less map and runs OIDC discovery on its own background context, like the v1 callback.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, translateOpenIDError(err)
|
return nil, translateOpenIDError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceInfo, ipAddress := requestClientInfo(ctx)
|
deviceInfo, ipAddress := requestClientInfo(ctx)
|
||||||
// OIDC logins are not "remember me" sessions; v1 always issues a short one.
|
// OIDC logins are not "remember me" sessions; v1 always issues a short one.
|
||||||
token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, false)
|
token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, false, oidcData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, translateDomainError(err)
|
return nil, translateDomainError(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ func TestHumaLogout(t *testing.T) {
|
||||||
// Create a session so logout has something to delete, then mint a JWT whose
|
// Create a session so logout has something to delete, then mint a JWT whose
|
||||||
// sid claim points at it.
|
// sid claim points at it.
|
||||||
s := db.NewSession()
|
s := db.NewSession()
|
||||||
session, err := models.CreateSession(s, testuser1.ID, "test", "127.0.0.1", false)
|
session, err := models.CreateSession(s, testuser1.ID, "test", "127.0.0.1", false, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, s.Commit())
|
require.NoError(t, s.Commit())
|
||||||
require.NoError(t, s.Close())
|
require.NoError(t, s.Close())
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue