Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package migrationscripts

import (
"github.com/apache/devlake/core/context"
"github.com/apache/devlake/core/errors"
"github.com/apache/devlake/core/plugin"
)

var _ plugin.MigrationScript = (*addIdTokenToAuthSessions)(nil)

type authSession20261107 struct {
IDToken string `gorm:"type:text"`
}

func (authSession20261107) TableName() string {
return "auth_sessions"
}

type addIdTokenToAuthSessions struct{}

func (*addIdTokenToAuthSessions) Up(basicRes context.BasicRes) errors.Error {
return basicRes.GetDal().AutoMigrate(&authSession20261107{})
}

func (*addIdTokenToAuthSessions) Version() uint64 {
return 20261107000001
}

func (*addIdTokenToAuthSessions) Name() string {
return "add id_token to auth_sessions for OIDC id_token_hint on logout"
}
1 change: 1 addition & 0 deletions backend/core/models/migrationscripts/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,5 +155,6 @@ func All() []plugin.MigrationScript {
new(backfillSubProjectFromMonorepo),
new(addBlueprintIdIndexToPipelines),
new(expandDomainTextColumns),
new(addIdTokenToAuthSessions),
}
}
49 changes: 49 additions & 0 deletions backend/helpers/oidchelper/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package oidchelper

import (
"fmt"
"net/url"
"sort"
"strings"
"time"
Expand All @@ -34,9 +35,17 @@ const (
// back as the X-CSRF-Token header (double-submit cookie pattern).
CSRFCookieName = "devlake_csrf"
CSRFHeaderName = "X-CSRF-Token"
// LogoutHintCookieName carries the encrypted id_token of a login that
// was rejected after a successful IdP sign-in (e.g. user not allowed),
// so "sign in with a different account" can end the IdP session
// without a confirmation prompt.
LogoutHintCookieName = "devlake_oidc_logout_hint"

// State cookie max-age. Covers a slow user typing 2FA at the IdP.
StateCookieMaxAge = 10 * time.Minute
// Logout-hint cookie max-age. Long enough to read the error page and
// click "sign in with a different account".
LogoutHintCookieMaxAge = 10 * time.Minute

defaultSessionTTL = 8 * time.Hour
defaultScopes = "openid,profile,email"
Expand All @@ -53,6 +62,11 @@ type ProviderConfig struct {
Scopes []string
DisplayName string

// PostLogoutRedirectURL is sent as post_logout_redirect_uri to the IdP's
// end_session_endpoint. Defaults to the origin of RedirectURL plus "/".
// Must be registered with the IdP as an allowed post-logout redirect.
PostLogoutRedirectURL string

// UseWorkloadIdentity authenticates the code exchange with an Azure
// Workload Identity federated assertion (read from the SA token file)
// instead of ClientSecret. Entra-specific.
Expand All @@ -72,6 +86,10 @@ type Config struct {
AllowEmails map[string]struct{}
AllowDomains map[string]struct{}

// AccessRequestURL is shown on the login error page so rejected users
// know where to request access. Optional; must be http(s).
AccessRequestURL string

SessionSecret []byte
SessionTTL time.Duration

Expand Down Expand Up @@ -145,6 +163,13 @@ func LoadConfig(basicRes context.BasicRes) (*Config, error) {
AllowDomains: parseStringSet(cfg.GetString("OIDC_ALLOW_DOMAINS")),
}

if v := strings.TrimSpace(cfg.GetString("OIDC_ACCESS_REQUEST_URL")); v != "" {
if !isHTTPURL(v) {
return nil, fmt.Errorf("OIDC_ACCESS_REQUEST_URL must be an absolute http(s) URL, got %q", v)
}
out.AccessRequestURL = v
}

if !out.OIDCEnabled {
return out, nil
}
Expand Down Expand Up @@ -194,9 +219,33 @@ func loadProviderConfig(cfg config.ConfigReader, name, prefix string) (*Provider
if len(missing) > 0 {
return nil, fmt.Errorf("provider %q missing required env vars: %s", name, strings.Join(missing, ", "))
}

if v := strings.TrimSpace(cfg.GetString(prefix + "POST_LOGOUT_REDIRECT_URL")); v != "" {
if !isHTTPURL(v) {
return nil, fmt.Errorf("%sPOST_LOGOUT_REDIRECT_URL must be an absolute http(s) URL, got %q", prefix, v)
}
p.PostLogoutRedirectURL = v
} else {
p.PostLogoutRedirectURL = originOf(p.RedirectURL)
}
return p, nil
}

func isHTTPURL(raw string) bool {
u, err := url.Parse(raw)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}

// originOf returns scheme://host/ of raw, or "" if raw is not an absolute
// http(s) URL.
func originOf(raw string) string {
if !isHTTPURL(raw) {
return ""
}
u, _ := url.Parse(raw)
return (&url.URL{Scheme: u.Scheme, Host: u.Host, Path: "/"}).String()
}

func parseProviderNames(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
Expand Down
61 changes: 61 additions & 0 deletions backend/helpers/oidchelper/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,64 @@ func TestLoadConfigRequiresSessionSecretForOIDC(t *testing.T) {
t.Fatal("LoadConfig should reject OIDC-enabled config without SESSION_SECRET")
}
}

func oidcTestViper() *viper.Viper {
v := viper.New()
v.Set("AUTH_ENABLED", true)
v.Set("OIDC_ENABLED", true)
v.Set("SESSION_SECRET", "test-secret-with-at-least-32-bytes!")
v.Set("OIDC_PROVIDERS", "keycloak")
v.Set("OIDC_KEYCLOAK_ISSUER_URL", "https://idp.example.com/realms/company")
v.Set("OIDC_KEYCLOAK_CLIENT_ID", "devlake")
v.Set("OIDC_KEYCLOAK_CLIENT_SECRET", "secret")
v.Set("OIDC_KEYCLOAK_REDIRECT_URL", "https://devlake.example.com:8443/api/auth/callback")
return v
}

func TestLoadConfigPostLogoutRedirectDefaultsToRedirectOrigin(t *testing.T) {
cfg, err := LoadConfig(basicResStub{cfg: oidcTestViper()})
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if got := cfg.Providers["keycloak"].PostLogoutRedirectURL; got != "https://devlake.example.com:8443/" {
t.Fatalf("PostLogoutRedirectURL = %q", got)
}
}

func TestLoadConfigPostLogoutRedirectExplicit(t *testing.T) {
v := oidcTestViper()
v.Set("OIDC_KEYCLOAK_POST_LOGOUT_REDIRECT_URL", "https://devlake.example.com/devlake/")
cfg, err := LoadConfig(basicResStub{cfg: v})
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if got := cfg.Providers["keycloak"].PostLogoutRedirectURL; got != "https://devlake.example.com/devlake/" {
t.Fatalf("PostLogoutRedirectURL = %q", got)
}
}

func TestLoadConfigRejectsInvalidURLs(t *testing.T) {
cases := map[string]string{
"OIDC_KEYCLOAK_POST_LOGOUT_REDIRECT_URL": "/relative",
"OIDC_ACCESS_REQUEST_URL": "javascript:alert(1)",
}
for key, value := range cases {
v := oidcTestViper()
v.Set(key, value)
if _, err := LoadConfig(basicResStub{cfg: v}); err == nil {
t.Errorf("LoadConfig should reject %s=%q", key, value)
}
}
}

func TestLoadConfigAccessRequestURL(t *testing.T) {
v := oidcTestViper()
v.Set("OIDC_ACCESS_REQUEST_URL", "https://intranet.example.com/devlake-access")
cfg, err := LoadConfig(basicResStub{cfg: v})
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.AccessRequestURL != "https://intranet.example.com/devlake-access" {
t.Fatalf("AccessRequestURL = %q", cfg.AccessRequestURL)
}
}
8 changes: 8 additions & 0 deletions backend/helpers/oidchelper/cookie.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ func ClearStateCookie(c *gin.Context, cfg *Config) {
writeCookie(c, cfg, StateCookieName, "", -1, true)
}

func SetLogoutHintCookie(c *gin.Context, cfg *Config, value string) {
writeCookie(c, cfg, LogoutHintCookieName, value, int(LogoutHintCookieMaxAge.Seconds()), true)
}

func ClearLogoutHintCookie(c *gin.Context, cfg *Config) {
writeCookie(c, cfg, LogoutHintCookieName, "", -1, true)
}

// SetCSRFCookie writes the CSRF token. Not HttpOnly so that JS can read
// it and echo the value back as the X-CSRF-Token header.
func SetCSRFCookie(c *gin.Context, cfg *Config, token string) {
Expand Down
26 changes: 24 additions & 2 deletions backend/helpers/oidchelper/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package oidchelper
import (
"context"
"fmt"
"net/url"
"sync"
"time"

Expand Down Expand Up @@ -111,7 +112,12 @@ func (p *Provider) VerifyIDToken(ctx context.Context, raw string) (*oidc.IDToken
return verifier.Verify(ctx, raw)
}

func (p *Provider) EndSessionURL(ctx context.Context) (string, error) {
// EndSessionURL builds an RP-Initiated Logout URL (OpenID Connect
// RP-Initiated Logout 1.0) for the IdP's end_session_endpoint. client_id
// and post_logout_redirect_uri let the IdP return the user to DevLake;
// idTokenHint (optional) lets IdPs such as Keycloak skip the logout
// confirmation prompt. Returns "" when the IdP advertises no endpoint.
func (p *Provider) EndSessionURL(ctx context.Context, idTokenHint string) (string, error) {
prov, err := p.OIDC(ctx)
if err != nil {
return "", err
Expand All @@ -122,5 +128,21 @@ func (p *Provider) EndSessionURL(ctx context.Context) (string, error) {
if err := prov.Claims(&claims); err != nil {
return "", err
}
return claims.EndSessionEndpoint, nil
if claims.EndSessionEndpoint == "" {
return "", nil
}
u, err := url.Parse(claims.EndSessionEndpoint)
if err != nil {
return "", fmt.Errorf("parse end_session_endpoint: %w", err)
}
q := u.Query()
q.Set("client_id", p.cfg.ClientID)
if p.cfg.PostLogoutRedirectURL != "" {
q.Set("post_logout_redirect_uri", p.cfg.PostLogoutRedirectURL)
}
if idTokenHint != "" {
q.Set("id_token_hint", idTokenHint)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
112 changes: 112 additions & 0 deletions backend/helpers/oidchelper/provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package oidchelper

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
)

// discoveryServer serves a minimal OIDC discovery document whose
// end_session_endpoint is built from endSessionPath ("" omits it).
func discoveryServer(t *testing.T, endSessionPath string) *httptest.Server {
t.Helper()
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
doc := map[string]any{
"issuer": srv.URL,
"authorization_endpoint": srv.URL + "/authorize",
"token_endpoint": srv.URL + "/token",
"jwks_uri": srv.URL + "/jwks",
}
if endSessionPath != "" {
doc["end_session_endpoint"] = srv.URL + endSessionPath
}
_ = json.NewEncoder(w).Encode(doc)
}))
t.Cleanup(srv.Close)
return srv
}

func TestEndSessionURLAddsLogoutParams(t *testing.T) {
srv := discoveryServer(t, "/logout?ui_locales=en")
p := NewProvider(&ProviderConfig{
IssuerURL: srv.URL,
ClientID: "devlake",
PostLogoutRedirectURL: "https://devlake.example.com/",
})

raw, err := p.EndSessionURL(context.Background(), "the-id-token")
if err != nil {
t.Fatalf("EndSessionURL: %v", err)
}
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse %q: %v", raw, err)
}
if u.Path != "/logout" {
t.Fatalf("path = %q", u.Path)
}
q := u.Query()
want := map[string]string{
"ui_locales": "en",
"client_id": "devlake",
"post_logout_redirect_uri": "https://devlake.example.com/",
"id_token_hint": "the-id-token",
}
for k, v := range want {
if q.Get(k) != v {
t.Errorf("%s = %q, want %q (url=%s)", k, q.Get(k), v, raw)
}
}
}

func TestEndSessionURLOmitsEmptyHint(t *testing.T) {
srv := discoveryServer(t, "/logout")
p := NewProvider(&ProviderConfig{IssuerURL: srv.URL, ClientID: "devlake"})

raw, err := p.EndSessionURL(context.Background(), "")
if err != nil {
t.Fatalf("EndSessionURL: %v", err)
}
u, _ := url.Parse(raw)
q := u.Query()
if q.Has("id_token_hint") || q.Has("post_logout_redirect_uri") {
t.Fatalf("unexpected optional params in %s", raw)
}
if q.Get("client_id") != "devlake" {
t.Fatalf("client_id missing in %s", raw)
}
}

func TestEndSessionURLEmptyWithoutEndpoint(t *testing.T) {
srv := discoveryServer(t, "")
p := NewProvider(&ProviderConfig{IssuerURL: srv.URL, ClientID: "devlake"})

raw, err := p.EndSessionURL(context.Background(), "hint")
if err != nil {
t.Fatalf("EndSessionURL: %v", err)
}
if raw != "" {
t.Fatalf("expected empty URL, got %q", raw)
}
}
Loading
Loading