You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@devlake.apache.org by kl...@apache.org on 2026/04/27 12:29:38 UTC
(incubator-devlake) branch main updated: feat(auth): implement oidc authentication and session management (#8854)
This is an automated email from the ASF dual-hosted git repository.
klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git
The following commit(s) were added to refs/heads/main by this push:
new 19ee801d9 feat(auth): implement oidc authentication and session management (#8854)
19ee801d9 is described below
commit 19ee801d909a160dafa190c4a81f0120a14c5602
Author: Richard Boisvert <rb...@devolutions.net>
AuthorDate: Mon Apr 27 08:29:34 2026 -0400
feat(auth): implement oidc authentication and session management (#8854)
DevLake had no native OIDC login. The existing protections: nginx Basic auth and /rest/* Bearer tokens, all sit outside the API and don't give you SSO at the application layer.
This adds an OIDC login flow behind a default-off AUTH_ENABLED switch. Multiple providers (Entra ID, Google, etc.) can be configured side by side, sessions are revocable server-side, and CSRF is enforced on state-changing requests when authenticating via the session cookie.
When AUTH_ENABLED=false the system behaves exactly as before; existing Basic auth, oauth2-proxy, and API-key flows are untouched.
Refs: https://github.com/apache/incubator-devlake/discussions/8694
Refs: https://devlake-io.slack.com/archives/C03AJH1HBAT/p1776917914734679
---
.../migrationscripts/20260426_add_auth_sessions.go | 58 +++
backend/core/models/migrationscripts/register.go | 1 +
backend/go.mod | 10 +-
backend/go.sum | 18 +-
backend/helpers/oidchelper/config.go | 223 +++++++++
backend/helpers/oidchelper/config_test.go | 86 ++++
backend/helpers/oidchelper/cookie.go | 63 +++
backend/helpers/oidchelper/provider.go | 126 +++++
backend/helpers/oidchelper/session.go | 85 ++++
backend/helpers/oidchelper/session_test.go | 86 ++++
backend/helpers/oidchelper/state.go | 118 +++++
backend/helpers/oidchelper/state_test.go | 110 +++++
backend/helpers/oidchelper/wif.go | 77 +++
backend/helpers/oidchelper/wif_test.go | 109 +++++
backend/server/api/api.go | 10 +-
backend/server/api/auth/auth.go | 528 +++++++++++++++++++++
backend/server/api/auth/auth_test.go | 51 ++
backend/server/api/auth/cleanup.go | 57 +++
backend/server/api/auth/handlers_test.go | 426 +++++++++++++++++
backend/server/api/auth/middleware.go | 165 +++++++
backend/server/api/auth/revocation_cache.go | 117 +++++
backend/server/api/auth/revocation_cache_test.go | 67 +++
backend/server/api/auth/store.go | 76 +++
backend/server/api/router.go | 8 +
config-ui/src/{routes => api/auth}/index.ts | 36 +-
config-ui/src/api/index.ts | 2 +
config-ui/src/app/{routrer.tsx => router.tsx} | 5 +
config-ui/src/main.tsx | 2 +-
.../src/plugins/register/github/transformation.tsx | 4 +-
config-ui/src/routes/index.ts | 1 +
config-ui/src/routes/layout/layout.tsx | 45 +-
config-ui/src/routes/layout/loader.ts | 9 +-
config-ui/src/routes/login/index.tsx | 84 ++++
config-ui/src/utils/request.ts | 15 +
config-ui/vite.config.ts | 4 +-
env.example | 66 ++-
36 files changed, 2915 insertions(+), 33 deletions(-)
diff --git a/backend/core/models/migrationscripts/20260426_add_auth_sessions.go b/backend/core/models/migrationscripts/20260426_add_auth_sessions.go
new file mode 100644
index 000000000..7e6eab8aa
--- /dev/null
+++ b/backend/core/models/migrationscripts/20260426_add_auth_sessions.go
@@ -0,0 +1,58 @@
+/*
+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 (
+ "time"
+
+ "github.com/apache/incubator-devlake/core/context"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ "github.com/apache/incubator-devlake/helpers/migrationhelper"
+)
+
+var _ plugin.MigrationScript = (*addAuthSessions)(nil)
+
+type authSession20260426 struct {
+ Jti string `gorm:"primaryKey;type:varchar(36)"`
+ Sub string `gorm:"type:varchar(255);index"`
+ Email string `gorm:"type:varchar(255)"`
+ Name string `gorm:"type:varchar(255)"`
+ IssuedAt time.Time `gorm:"not null"`
+ ExpiresAt time.Time `gorm:"index;not null"`
+ RevokedAt *time.Time `gorm:"index"`
+ LastSeenAt time.Time
+}
+
+func (authSession20260426) TableName() string {
+ return "auth_sessions"
+}
+
+type addAuthSessions struct{}
+
+func (*addAuthSessions) Up(basicRes context.BasicRes) errors.Error {
+ return migrationhelper.AutoMigrateTables(basicRes, new(authSession20260426))
+}
+
+func (*addAuthSessions) Version() uint64 {
+ return 20260426000001
+}
+
+func (*addAuthSessions) Name() string {
+ return "add auth_sessions table for session revocation"
+}
diff --git a/backend/core/models/migrationscripts/register.go b/backend/core/models/migrationscripts/register.go
index 9abb4f0ae..b4596154e 100644
--- a/backend/core/models/migrationscripts/register.go
+++ b/backend/core/models/migrationscripts/register.go
@@ -144,5 +144,6 @@ func All() []plugin.MigrationScript {
new(fixNullPriority),
new(modifyCicdDeploymentsToText),
new(increaseCqIssuesProjectKeyLength),
+ new(addAuthSessions),
}
}
diff --git a/backend/go.mod b/backend/go.mod
index b744729e1..47e7ad31c 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -32,7 +32,7 @@ require (
github.com/viant/afs v1.16.0
golang.org/x/crypto v0.26.0 // indirect
golang.org/x/exp v0.0.0-20221028150844-83b7d23a625f
- golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602
+ golang.org/x/oauth2 v0.13.0
golang.org/x/sync v0.8.0
gorm.io/datatypes v1.0.1
gorm.io/driver/mysql v1.5.1
@@ -42,6 +42,7 @@ require (
require (
github.com/chainguard-dev/git-urls v1.0.2
+ github.com/coreos/go-oidc/v3 v3.9.0
github.com/go-sql-driver/mysql v1.7.1
github.com/golang-jwt/jwt/v5 v5.0.0-rc.1
github.com/merico-ai/graphql v0.0.0-20260206020408-b7fd267bcfac
@@ -70,6 +71,7 @@ require (
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.5.0 // indirect
+ github.com/go-jose/go-jose/v3 v3.0.1 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
@@ -79,7 +81,7 @@ require (
github.com/goccy/go-json v0.10.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
- github.com/golang/protobuf v1.5.2 // indirect
+ github.com/golang/protobuf v1.5.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -121,8 +123,8 @@ require (
golang.org/x/sys v0.23.0 // indirect
golang.org/x/text v0.17.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
- google.golang.org/appengine v1.6.7 // indirect
- google.golang.org/protobuf v1.30.0 // indirect
+ google.golang.org/appengine v1.6.8 // indirect
+ google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
diff --git a/backend/go.sum b/backend/go.sum
index 2344cde8b..765636622 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -89,6 +89,8 @@ github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZe
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
+github.com/coreos/go-oidc/v3 v3.9.0 h1:0J/ogVOd4y8P0f0xUh8l9t07xRP/d8tccvjHl2dcsSo=
+github.com/coreos/go-oidc/v3 v3.9.0/go.mod h1:rTKz2PYwftcrtoCzV5g5kvfJoWcm0Mk8AF8y1iAQro4=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
@@ -140,6 +142,8 @@ github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXY
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
+github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
@@ -204,8 +208,9 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
-github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -657,8 +662,9 @@ golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602 h1:0Ja1LBD+yisY6RWM/BH7TJVXWsSjs2VwBSmvSX4HdBc=
golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY=
+golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -754,6 +760,7 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
@@ -855,8 +862,9 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
+google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@@ -930,8 +938,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
-google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/backend/helpers/oidchelper/config.go b/backend/helpers/oidchelper/config.go
new file mode 100644
index 000000000..4b45dfa3b
--- /dev/null
+++ b/backend/helpers/oidchelper/config.go
@@ -0,0 +1,223 @@
+/*
+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 (
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/config"
+ "github.com/apache/incubator-devlake/core/context"
+)
+
+const (
+ SessionCookieName = "devlake_session"
+ StateCookieName = "devlake_oauth_state"
+ // CSRFCookieName is intentionally readable by JS so axios can echo it
+ // back as the X-CSRF-Token header (double-submit cookie pattern).
+ CSRFCookieName = "devlake_csrf"
+ CSRFHeaderName = "X-CSRF-Token"
+
+ // State cookie max-age. Covers a slow user typing 2FA at the IdP.
+ StateCookieMaxAge = 10 * time.Minute
+
+ defaultSessionTTL = 8 * time.Hour
+ defaultScopes = "openid,profile,email"
+)
+
+// ProviderConfig holds everything specific to one OIDC IdP. Multiple
+// instances live in Config.Providers, keyed by Name.
+type ProviderConfig struct {
+ Name string
+ IssuerURL string
+ ClientID string
+ ClientSecret string
+ RedirectURL string
+ Scopes []string
+ DisplayName string
+
+ // UseWorkloadIdentity authenticates the code exchange with an Azure
+ // Workload Identity federated assertion (read from the SA token file)
+ // instead of ClientSecret. Entra-specific.
+ UseWorkloadIdentity bool
+}
+
+// Config is the typed view of the auth-related env vars. Build it once at boot.
+type Config struct {
+ AuthEnabled bool
+
+ OIDCEnabled bool
+ Providers map[string]*ProviderConfig
+ LogoutRedirect bool
+
+ SessionSecret []byte
+ SessionTTL time.Duration
+
+ CookieDomain string
+ CookieSecure bool
+}
+
+// ProviderNames returns the configured provider names in stable order.
+func (c *Config) ProviderNames() []string {
+ if c == nil {
+ return nil
+ }
+ out := make([]string, 0, len(c.Providers))
+ for name := range c.Providers {
+ out = append(out, name)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// LoadConfig reads auth env vars via Viper and validates required fields.
+// Returns Config{AuthEnabled:false} when AUTH_ENABLED=false (the default,
+// preserves historical behavior).
+func LoadConfig(basicRes context.BasicRes) (*Config, error) {
+ cfg := basicRes.GetConfigReader()
+
+ if !cfg.GetBool("AUTH_ENABLED") {
+ return &Config{AuthEnabled: false}, nil
+ }
+
+ sessionSecret := strings.TrimSpace(cfg.GetString("SESSION_SECRET"))
+ if sessionSecret == "" {
+ return nil, fmt.Errorf("AUTH_ENABLED=true but SESSION_SECRET is not set")
+ }
+ if len(sessionSecret) < 32 {
+ return nil, fmt.Errorf("SESSION_SECRET must be at least 32 bytes")
+ }
+
+ ttl := defaultSessionTTL
+ if v := strings.TrimSpace(cfg.GetString("SESSION_TTL")); v != "" {
+ parsed, err := time.ParseDuration(v)
+ if err != nil {
+ return nil, fmt.Errorf("invalid SESSION_TTL %q: %w", v, err)
+ }
+ ttl = parsed
+ }
+
+ cookieSecure := true
+ if cfg.IsSet("COOKIE_SECURE") {
+ cookieSecure = cfg.GetBool("COOKIE_SECURE")
+ }
+
+ out := &Config{
+ AuthEnabled: true,
+ OIDCEnabled: cfg.GetBool("OIDC_ENABLED"),
+ Providers: map[string]*ProviderConfig{},
+ LogoutRedirect: cfg.GetBool("OIDC_LOGOUT_REDIRECT"),
+ SessionSecret: []byte(sessionSecret),
+ SessionTTL: ttl,
+ CookieDomain: strings.TrimSpace(cfg.GetString("COOKIE_DOMAIN")),
+ CookieSecure: cookieSecure,
+ }
+
+ if !out.OIDCEnabled {
+ return out, nil
+ }
+
+ names := parseProviderNames(cfg.GetString("OIDC_PROVIDERS"))
+ if len(names) == 0 {
+ return nil, fmt.Errorf("OIDC_ENABLED=true but OIDC_PROVIDERS is empty")
+ }
+ for _, name := range names {
+ prefix := "OIDC_" + strings.ToUpper(name) + "_"
+ p, err := loadProviderConfig(cfg, name, prefix)
+ if err != nil {
+ return nil, err
+ }
+ out.Providers[name] = p
+ }
+ return out, nil
+}
+
+func loadProviderConfig(cfg config.ConfigReader, name, prefix string) (*ProviderConfig, error) {
+ p := &ProviderConfig{
+ Name: name,
+ IssuerURL: strings.TrimSpace(cfg.GetString(prefix + "ISSUER_URL")),
+ ClientID: strings.TrimSpace(cfg.GetString(prefix + "CLIENT_ID")),
+ ClientSecret: strings.TrimSpace(cfg.GetString(prefix + "CLIENT_SECRET")),
+ RedirectURL: strings.TrimSpace(cfg.GetString(prefix + "REDIRECT_URL")),
+ Scopes: parseScopes(cfg.GetString(prefix + "SCOPES")),
+ DisplayName: valueOr(cfg.GetString(prefix+"DISPLAY_NAME"), name),
+ UseWorkloadIdentity: cfg.GetBool(prefix + "USE_WORKLOAD_IDENTITY"),
+ }
+
+ missing := []string{}
+ if p.IssuerURL == "" {
+ missing = append(missing, prefix+"ISSUER_URL")
+ }
+ if p.ClientID == "" {
+ missing = append(missing, prefix+"CLIENT_ID")
+ }
+ // Workload Identity replaces the static client secret with a federated
+ // assertion read at exchange time, so the secret is optional in that mode.
+ if p.ClientSecret == "" && !p.UseWorkloadIdentity {
+ missing = append(missing, prefix+"CLIENT_SECRET")
+ }
+ if p.RedirectURL == "" {
+ missing = append(missing, prefix+"REDIRECT_URL")
+ }
+ if len(missing) > 0 {
+ return nil, fmt.Errorf("provider %q missing required env vars: %s", name, strings.Join(missing, ", "))
+ }
+ return p, nil
+}
+
+func parseProviderNames(raw string) []string {
+ parts := strings.Split(raw, ",")
+ out := make([]string, 0, len(parts))
+ seen := map[string]struct{}{}
+ for _, p := range parts {
+ n := strings.ToLower(strings.TrimSpace(p))
+ if n == "" {
+ continue
+ }
+ if _, dup := seen[n]; dup {
+ continue
+ }
+ seen[n] = struct{}{}
+ out = append(out, n)
+ }
+ return out
+}
+
+func parseScopes(raw string) []string {
+ if strings.TrimSpace(raw) == "" {
+ raw = defaultScopes
+ }
+ parts := strings.Split(raw, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ s := strings.TrimSpace(p)
+ if s != "" {
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+func valueOr(s, fallback string) string {
+ if strings.TrimSpace(s) == "" {
+ return fallback
+ }
+ return s
+}
diff --git a/backend/helpers/oidchelper/config_test.go b/backend/helpers/oidchelper/config_test.go
new file mode 100644
index 000000000..1924fbb53
--- /dev/null
+++ b/backend/helpers/oidchelper/config_test.go
@@ -0,0 +1,86 @@
+/*
+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 (
+ "reflect"
+ "testing"
+)
+
+func TestParseScopes(t *testing.T) {
+ cases := map[string][]string{
+ "": {"openid", "profile", "email"},
+ " ": {"openid", "profile", "email"},
+ "openid": {"openid"},
+ "openid,profile": {"openid", "profile"},
+ " openid , profile , email ": {"openid", "profile", "email"},
+ "openid,,email": {"openid", "email"},
+ }
+ for input, want := range cases {
+ got := parseScopes(input)
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("parseScopes(%q) = %v, want %v", input, got, want)
+ }
+ }
+}
+
+func TestValueOr(t *testing.T) {
+ if got := valueOr("hello", "fallback"); got != "hello" {
+ t.Errorf("valueOr(hello) = %q", got)
+ }
+ if got := valueOr("", "fallback"); got != "fallback" {
+ t.Errorf("valueOr(empty) = %q", got)
+ }
+ if got := valueOr(" ", "fallback"); got != "fallback" {
+ t.Errorf("valueOr(whitespace) = %q", got)
+ }
+}
+
+func TestParseProviderNames(t *testing.T) {
+ cases := map[string][]string{
+ "": {},
+ " ": {},
+ "entra": {"entra"},
+ "entra,google": {"entra", "google"},
+ " Entra , GOOGLE": {"entra", "google"},
+ "entra,,google": {"entra", "google"},
+ "entra,entra": {"entra"},
+ "Entra,entra": {"entra"},
+ }
+ for input, want := range cases {
+ got := parseProviderNames(input)
+ if len(got) == 0 && len(want) == 0 {
+ continue
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("parseProviderNames(%q) = %v, want %v", input, got, want)
+ }
+ }
+}
+
+func TestProviderNamesSorted(t *testing.T) {
+ c := &Config{
+ Providers: map[string]*ProviderConfig{
+ "google": {Name: "google"},
+ "entra": {Name: "entra"},
+ },
+ }
+ if names := c.ProviderNames(); !reflect.DeepEqual(names, []string{"entra", "google"}) {
+ t.Errorf("ProviderNames = %v, want sorted [entra google]", names)
+ }
+}
diff --git a/backend/helpers/oidchelper/cookie.go b/backend/helpers/oidchelper/cookie.go
new file mode 100644
index 000000000..c628d07e8
--- /dev/null
+++ b/backend/helpers/oidchelper/cookie.go
@@ -0,0 +1,63 @@
+/*
+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 (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+func writeCookie(c *gin.Context, cfg *Config, name, value string, maxAge int, httpOnly bool) {
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: name,
+ Value: value,
+ Path: "/",
+ Domain: cfg.CookieDomain,
+ MaxAge: maxAge,
+ HttpOnly: httpOnly,
+ Secure: cfg.CookieSecure,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func SetSessionCookie(c *gin.Context, cfg *Config, jwt string) {
+ writeCookie(c, cfg, SessionCookieName, jwt, int(cfg.SessionTTL.Seconds()), true)
+}
+
+func ClearSessionCookie(c *gin.Context, cfg *Config) {
+ writeCookie(c, cfg, SessionCookieName, "", -1, true)
+}
+
+func SetStateCookie(c *gin.Context, cfg *Config, value string) {
+ writeCookie(c, cfg, StateCookieName, value, int(StateCookieMaxAge.Seconds()), true)
+}
+
+func ClearStateCookie(c *gin.Context, cfg *Config) {
+ writeCookie(c, cfg, StateCookieName, "", -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) {
+ writeCookie(c, cfg, CSRFCookieName, token, int(cfg.SessionTTL.Seconds()), false)
+}
+
+func ClearCSRFCookie(c *gin.Context, cfg *Config) {
+ writeCookie(c, cfg, CSRFCookieName, "", -1, false)
+}
diff --git a/backend/helpers/oidchelper/provider.go b/backend/helpers/oidchelper/provider.go
new file mode 100644
index 000000000..bf2b10ac5
--- /dev/null
+++ b/backend/helpers/oidchelper/provider.go
@@ -0,0 +1,126 @@
+/*
+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"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+ "golang.org/x/oauth2"
+)
+
+// minRefreshInterval rate-limits provider re-discovery so a flood of bad
+// tokens cannot stampede the IdP's well-known endpoint.
+const minRefreshInterval = 30 * time.Second
+
+// Provider lazily resolves the OIDC discovery document for one IdP and
+// caches the resulting *oidc.Provider. Re-initializes on signature-
+// verification failure to handle JWKS key rotation.
+type Provider struct {
+ cfg *ProviderConfig
+
+ mu sync.RWMutex
+ provider *oidc.Provider
+ lastRefresh time.Time
+}
+
+func NewProvider(cfg *ProviderConfig) *Provider {
+ return &Provider{cfg: cfg}
+}
+
+func (p *Provider) Name() string { return p.cfg.Name }
+
+func (p *Provider) OIDC(ctx context.Context) (*oidc.Provider, error) {
+ p.mu.RLock()
+ cached := p.provider
+ p.mu.RUnlock()
+ if cached != nil {
+ return cached, nil
+ }
+ return p.refresh(ctx)
+}
+
+// refresh re-initializes the provider, but no more often than
+// minRefreshInterval to prevent a stampede of failed verifications from
+// hammering the IdP. Callers receive the cached provider when rate-limited.
+func (p *Provider) refresh(ctx context.Context) (*oidc.Provider, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.provider != nil && time.Since(p.lastRefresh) < minRefreshInterval {
+ return p.provider, nil
+ }
+ prov, err := oidc.NewProvider(ctx, p.cfg.IssuerURL)
+ if err != nil {
+ return nil, fmt.Errorf("oidc discovery (%s): %w", p.cfg.IssuerURL, err)
+ }
+ p.provider = prov
+ p.lastRefresh = time.Now()
+ return prov, nil
+}
+
+func (p *Provider) OAuth2Config(ctx context.Context) (*oauth2.Config, error) {
+ prov, err := p.OIDC(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return &oauth2.Config{
+ ClientID: p.cfg.ClientID,
+ ClientSecret: p.cfg.ClientSecret,
+ RedirectURL: p.cfg.RedirectURL,
+ Endpoint: prov.Endpoint(),
+ Scopes: p.cfg.Scopes,
+ }, nil
+}
+
+// VerifyIDToken validates the raw ID token against the provider's JWKS.
+// On signature-verification failure it forces a JWKS refresh (subject to
+// minRefreshInterval) and retries once, transparently handling key rotation.
+func (p *Provider) VerifyIDToken(ctx context.Context, raw string) (*oidc.IDToken, error) {
+ prov, err := p.OIDC(ctx)
+ if err != nil {
+ return nil, err
+ }
+ verifier := prov.Verifier(&oidc.Config{ClientID: p.cfg.ClientID})
+ tok, err := verifier.Verify(ctx, raw)
+ if err == nil {
+ return tok, nil
+ }
+ prov, refreshErr := p.refresh(ctx)
+ if refreshErr != nil {
+ return nil, fmt.Errorf("verify id_token: %w (refresh also failed: %v)", err, refreshErr)
+ }
+ verifier = prov.Verifier(&oidc.Config{ClientID: p.cfg.ClientID})
+ return verifier.Verify(ctx, raw)
+}
+
+func (p *Provider) EndSessionURL(ctx context.Context) (string, error) {
+ prov, err := p.OIDC(ctx)
+ if err != nil {
+ return "", err
+ }
+ var claims struct {
+ EndSessionEndpoint string `json:"end_session_endpoint"`
+ }
+ if err := prov.Claims(&claims); err != nil {
+ return "", err
+ }
+ return claims.EndSessionEndpoint, nil
+}
diff --git a/backend/helpers/oidchelper/session.go b/backend/helpers/oidchelper/session.go
new file mode 100644
index 000000000..3421cb99b
--- /dev/null
+++ b/backend/helpers/oidchelper/session.go
@@ -0,0 +1,85 @@
+/*
+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 (
+ "fmt"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+const sessionIssuer = "devlake"
+
+// sessionParser is reused across the per-request session-validation path to
+// avoid re-allocating the validator options on every call.
+var sessionParser = jwt.NewParser(
+ jwt.WithIssuer(sessionIssuer),
+ jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
+)
+
+type SessionClaims struct {
+ Provider string `json:"prv,omitempty"`
+ Email string `json:"email,omitempty"`
+ Name string `json:"name,omitempty"`
+ jwt.RegisteredClaims
+}
+
+// IssueSession signs a session JWT carrying the jti, the provider name (so
+// /auth/logout can find the right end_session_endpoint), and the user-facing
+// claims. The jti lets the server-side revocation table address one session.
+func IssueSession(cfg *Config, jti, provider, sub, email, name string) (string, time.Time, error) {
+ now := time.Now()
+ expiresAt := now.Add(cfg.SessionTTL)
+ claims := SessionClaims{
+ Provider: provider,
+ Email: email,
+ Name: name,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ID: jti,
+ Issuer: sessionIssuer,
+ Subject: sub,
+ IssuedAt: jwt.NewNumericDate(now),
+ ExpiresAt: jwt.NewNumericDate(expiresAt),
+ NotBefore: jwt.NewNumericDate(now),
+ },
+ }
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ signed, err := token.SignedString(cfg.SessionSecret)
+ if err != nil {
+ return "", time.Time{}, err
+ }
+ return signed, expiresAt, nil
+}
+
+func ParseSession(secret []byte, raw string) (*SessionClaims, error) {
+ parsed, err := sessionParser.ParseWithClaims(raw, &SessionClaims{}, func(t *jwt.Token) (any, error) {
+ if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
+ }
+ return secret, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ claims, ok := parsed.Claims.(*SessionClaims)
+ if !ok || !parsed.Valid {
+ return nil, fmt.Errorf("invalid session token")
+ }
+ return claims, nil
+}
diff --git a/backend/helpers/oidchelper/session_test.go b/backend/helpers/oidchelper/session_test.go
new file mode 100644
index 000000000..588210e88
--- /dev/null
+++ b/backend/helpers/oidchelper/session_test.go
@@ -0,0 +1,86 @@
+/*
+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 (
+ "testing"
+ "time"
+)
+
+func newTestCfg(secret string, ttl time.Duration) *Config {
+ return &Config{SessionSecret: []byte(secret), SessionTTL: ttl}
+}
+
+func TestSessionRoundTrip(t *testing.T) {
+ cfg := newTestCfg("session-test-secret-32-bytes!!", time.Hour)
+ jwt, exp, err := IssueSession(cfg, "jti-1", "entra", "user-1", "u@example.com", "Alice")
+ if err != nil {
+ t.Fatalf("issue: %v", err)
+ }
+ if exp.IsZero() {
+ t.Fatalf("expected non-zero expiry")
+ }
+ claims, err := ParseSession(cfg.SessionSecret, jwt)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if claims.Subject != "user-1" || claims.Email != "u@example.com" || claims.Name != "Alice" {
+ t.Fatalf("unexpected claims: %+v", claims)
+ }
+ if claims.ID != "jti-1" {
+ t.Fatalf("expected jti claim, got %q", claims.ID)
+ }
+ if claims.Provider != "entra" {
+ t.Fatalf("expected provider claim, got %q", claims.Provider)
+ }
+}
+
+func TestSessionRejectsExpired(t *testing.T) {
+ cfg := newTestCfg("session-test-secret-32-bytes!!", -1*time.Second)
+ jwt, _, err := IssueSession(cfg, "jti-1", "entra", "user-1", "u@example.com", "Alice")
+ if err != nil {
+ t.Fatalf("issue: %v", err)
+ }
+ if _, err := ParseSession(cfg.SessionSecret, jwt); err == nil {
+ t.Fatal("expected expired token to fail validation")
+ }
+}
+
+func TestSessionRejectsWrongSecret(t *testing.T) {
+ a := newTestCfg("session-test-secret-32-bytes!a", time.Hour)
+ b := newTestCfg("session-test-secret-32-bytes!b", time.Hour)
+ jwt, _, err := IssueSession(a, "jti-1", "entra", "user-1", "", "")
+ if err != nil {
+ t.Fatalf("issue: %v", err)
+ }
+ if _, err := ParseSession(b.SessionSecret, jwt); err == nil {
+ t.Fatal("expected wrong-secret to fail validation")
+ }
+}
+
+func TestSessionRejectsTampered(t *testing.T) {
+ cfg := newTestCfg("session-test-secret-32-bytes!!", time.Hour)
+ jwt, _, err := IssueSession(cfg, "jti-1", "entra", "user-1", "", "")
+ if err != nil {
+ t.Fatalf("issue: %v", err)
+ }
+ tampered := jwt[:len(jwt)-2] + "AA"
+ if _, err := ParseSession(cfg.SessionSecret, tampered); err == nil {
+ t.Fatal("expected tampered token to fail validation")
+ }
+}
diff --git a/backend/helpers/oidchelper/state.go b/backend/helpers/oidchelper/state.go
new file mode 100644
index 000000000..d43b82fdf
--- /dev/null
+++ b/backend/helpers/oidchelper/state.go
@@ -0,0 +1,118 @@
+/*
+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 (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "time"
+)
+
+// StatePayload is encrypted into the state cookie. The Nonce field is also
+// echoed as the OIDC `state` query parameter so we can verify the user's
+// browser is the one that initiated the flow. Provider names which IdP
+// minted this flow so the callback handler picks the right token endpoint.
+type StatePayload struct {
+ Provider string `json:"v"`
+ Nonce string `json:"n"`
+ ReturnURL string `json:"r"`
+ PKCEVerifier string `json:"p"`
+ IssuedAt time.Time `json:"t"`
+}
+
+// EncodeState seals a StatePayload with AES-GCM (key derived from
+// SESSION_SECRET via SHA-256) and returns a URL-safe base64 string.
+func EncodeState(secret []byte, p *StatePayload) (string, error) {
+ gcm, err := newGCM(secret)
+ if err != nil {
+ return "", err
+ }
+ plaintext, err := json.Marshal(p)
+ if err != nil {
+ return "", err
+ }
+ nonce := make([]byte, gcm.NonceSize())
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ return "", err
+ }
+ ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
+ return base64.RawURLEncoding.EncodeToString(ciphertext), nil
+}
+
+// DecodeState reverses EncodeState and rejects payloads older than StateCookieMaxAge.
+func DecodeState(secret []byte, encoded string) (*StatePayload, error) {
+ gcm, err := newGCM(secret)
+ if err != nil {
+ return nil, err
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(encoded)
+ if err != nil {
+ return nil, fmt.Errorf("state decode: %w", err)
+ }
+ if len(raw) < gcm.NonceSize() {
+ return nil, fmt.Errorf("state too short")
+ }
+ nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
+ plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
+ if err != nil {
+ return nil, fmt.Errorf("state decrypt: %w", err)
+ }
+ var p StatePayload
+ if err := json.Unmarshal(plaintext, &p); err != nil {
+ return nil, fmt.Errorf("state unmarshal: %w", err)
+ }
+ if time.Since(p.IssuedAt) > StateCookieMaxAge {
+ return nil, fmt.Errorf("state expired")
+ }
+ return &p, nil
+}
+
+// NewNonce returns a 24-char base64 random string suitable for the OIDC
+// `state` parameter (~144 bits of entropy).
+func NewNonce() (string, error) {
+ b := make([]byte, 18)
+ if _, err := io.ReadFull(rand.Reader, b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+// NewCSRFToken returns a 256-bit random URL-safe string for double-submit CSRF.
+func NewCSRFToken() (string, error) {
+ b := make([]byte, 32)
+ if _, err := io.ReadFull(rand.Reader, b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+func newGCM(secret []byte) (cipher.AEAD, error) {
+ // AES-256 needs a 32-byte key; SHA-256 of the secret gives one.
+ sum := sha256.Sum256(secret)
+ block, err := aes.NewCipher(sum[:])
+ if err != nil {
+ return nil, err
+ }
+ return cipher.NewGCM(block)
+}
diff --git a/backend/helpers/oidchelper/state_test.go b/backend/helpers/oidchelper/state_test.go
new file mode 100644
index 000000000..3401cd372
--- /dev/null
+++ b/backend/helpers/oidchelper/state_test.go
@@ -0,0 +1,110 @@
+/*
+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 (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestEncodeDecodeStateRoundTrip(t *testing.T) {
+ secret := []byte("a-test-secret-with-at-least-32-bytes!")
+ in := &StatePayload{
+ Nonce: "abc123",
+ ReturnURL: "/projects",
+ PKCEVerifier: "verifier",
+ IssuedAt: time.Now(),
+ }
+ encoded, err := EncodeState(secret, in)
+ if err != nil {
+ t.Fatalf("encode: %v", err)
+ }
+ out, err := DecodeState(secret, encoded)
+ if err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if out.Nonce != in.Nonce || out.ReturnURL != in.ReturnURL || out.PKCEVerifier != in.PKCEVerifier {
+ t.Fatalf("round-trip mismatch: %+v vs %+v", in, out)
+ }
+}
+
+func TestDecodeStateRejectsTamperedCiphertext(t *testing.T) {
+ secret := []byte("a-test-secret-with-at-least-32-bytes!")
+ encoded, err := EncodeState(secret, &StatePayload{Nonce: "n", IssuedAt: time.Now()})
+ if err != nil {
+ t.Fatalf("encode: %v", err)
+ }
+ // Flip a char in the middle of the ciphertext (past the AES-GCM nonce
+ // prefix); flipping the very last char of base64 can be a no-op when
+ // the trailing bits are unused, defeating the test.
+ mid := len(encoded) / 2
+ tampered := encoded[:mid] + flipChar(encoded[mid]) + encoded[mid+1:]
+ if _, err := DecodeState(secret, tampered); err == nil {
+ t.Fatal("expected decode to fail on tampered ciphertext")
+ }
+}
+
+func TestDecodeStateRejectsWrongSecret(t *testing.T) {
+ encoded, err := EncodeState([]byte("first-secret-with-at-least-32-bytes!"), &StatePayload{Nonce: "n", IssuedAt: time.Now()})
+ if err != nil {
+ t.Fatalf("encode: %v", err)
+ }
+ if _, err := DecodeState([]byte("other-secret-with-at-least-32-bytes!"), encoded); err == nil {
+ t.Fatal("expected decode to fail with a different secret")
+ }
+}
+
+func TestDecodeStateRejectsExpiredPayload(t *testing.T) {
+ secret := []byte("a-test-secret-with-at-least-32-bytes!")
+ encoded, err := EncodeState(secret, &StatePayload{
+ Nonce: "n",
+ IssuedAt: time.Now().Add(-30 * time.Minute),
+ })
+ if err != nil {
+ t.Fatalf("encode: %v", err)
+ }
+ _, err = DecodeState(secret, encoded)
+ if err == nil || !strings.Contains(err.Error(), "expired") {
+ t.Fatalf("expected expired error, got: %v", err)
+ }
+}
+
+func TestNewNonceUniqueness(t *testing.T) {
+ seen := map[string]struct{}{}
+ for i := 0; i < 50; i++ {
+ n, err := NewNonce()
+ if err != nil {
+ t.Fatalf("NewNonce: %v", err)
+ }
+ if len(n) < 20 {
+ t.Fatalf("nonce too short: %q", n)
+ }
+ if _, dup := seen[n]; dup {
+ t.Fatalf("duplicate nonce: %q", n)
+ }
+ seen[n] = struct{}{}
+ }
+}
+
+func flipChar(b byte) string {
+ if b == 'A' {
+ return "B"
+ }
+ return "A"
+}
diff --git a/backend/helpers/oidchelper/wif.go b/backend/helpers/oidchelper/wif.go
new file mode 100644
index 000000000..0b03c4345
--- /dev/null
+++ b/backend/helpers/oidchelper/wif.go
@@ -0,0 +1,77 @@
+/*
+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 (
+ "fmt"
+ "os"
+ "sync"
+ "time"
+)
+
+// AzureFederatedTokenFileEnv is the env var the azure-workload-identity
+// webhook sets on the pod, pointing at the projected SA token file.
+const AzureFederatedTokenFileEnv = "AZURE_FEDERATED_TOKEN_FILE"
+
+// wifCacheTTL is the read-cache lifetime for the federated SA token.
+// Kubernetes rotates SA tokens at ~80% of their TTL (1h minimum), so 10
+// minutes is well inside the safe window.
+const wifCacheTTL = 10 * time.Minute
+
+var (
+ wifMu sync.RWMutex
+ wifAssertion string
+ wifExpires time.Time
+ // wifReadFile is overridable from tests so they can stub the file read
+ // without touching the real filesystem.
+ wifReadFile = os.ReadFile
+)
+
+// FederatedAssertion returns the workload-identity SA token to use as the
+// `client_assertion` in an OIDC code exchange against Microsoft Entra.
+// Reads the file pointed to by AZURE_FEDERATED_TOKEN_FILE and caches the
+// result for wifCacheTTL.
+func FederatedAssertion() (string, error) {
+ file := os.Getenv(AzureFederatedTokenFileEnv)
+ if file == "" {
+ return "", fmt.Errorf("%s env var not set (is the azure-workload-identity webhook installed and the pod labeled)", AzureFederatedTokenFileEnv)
+ }
+
+ wifMu.RLock()
+ if wifExpires.After(time.Now()) {
+ assertion := wifAssertion
+ wifMu.RUnlock()
+ return assertion, nil
+ }
+ wifMu.RUnlock()
+
+ wifMu.Lock()
+ defer wifMu.Unlock()
+ // Double-check under the write lock in case a peer goroutine already
+ // refreshed the assertion.
+ if wifExpires.After(time.Now()) {
+ return wifAssertion, nil
+ }
+ content, err := wifReadFile(file)
+ if err != nil {
+ return "", fmt.Errorf("read %s: %w", file, err)
+ }
+ wifAssertion = string(content)
+ wifExpires = time.Now().Add(wifCacheTTL)
+ return wifAssertion, nil
+}
diff --git a/backend/helpers/oidchelper/wif_test.go b/backend/helpers/oidchelper/wif_test.go
new file mode 100644
index 000000000..89c326144
--- /dev/null
+++ b/backend/helpers/oidchelper/wif_test.go
@@ -0,0 +1,109 @@
+/*
+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 (
+ "errors"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func resetFederatedAssertionCache() {
+ wifMu.Lock()
+ defer wifMu.Unlock()
+ wifAssertion = ""
+ wifExpires = time.Time{}
+}
+
+func TestFederatedAssertionMissingEnv(t *testing.T) {
+ resetFederatedAssertionCache()
+ t.Setenv(AzureFederatedTokenFileEnv, "")
+ if _, err := FederatedAssertion(); err == nil {
+ t.Fatal("expected error when env var is unset")
+ }
+}
+
+func TestFederatedAssertionReadsAndCaches(t *testing.T) {
+ resetFederatedAssertionCache()
+ var reads atomic.Int32
+ wifReadFile = func(string) ([]byte, error) {
+ reads.Add(1)
+ return []byte("token-content"), nil
+ }
+ t.Cleanup(func() { wifReadFile = osReadFileForTests })
+
+ t.Setenv(AzureFederatedTokenFileEnv, "/fake/path")
+
+ for i := 0; i < 5; i++ {
+ got, err := FederatedAssertion()
+ if err != nil {
+ t.Fatalf("call %d: %v", i, err)
+ }
+ if got != "token-content" {
+ t.Fatalf("got %q", got)
+ }
+ }
+ if got := reads.Load(); got != 1 {
+ t.Fatalf("expected 1 file read across 5 calls, got %d", got)
+ }
+}
+
+func TestFederatedAssertionRefreshesAfterTTL(t *testing.T) {
+ resetFederatedAssertionCache()
+ var counter atomic.Int32
+ wifReadFile = func(string) ([]byte, error) {
+ counter.Add(1)
+ return []byte("v"), nil
+ }
+ t.Cleanup(func() { wifReadFile = osReadFileForTests })
+
+ t.Setenv(AzureFederatedTokenFileEnv, "/fake/path")
+
+ if _, err := FederatedAssertion(); err != nil {
+ t.Fatalf("first read: %v", err)
+ }
+ // Force the cache stale.
+ wifMu.Lock()
+ wifExpires = time.Now().Add(-time.Second)
+ wifMu.Unlock()
+
+ if _, err := FederatedAssertion(); err != nil {
+ t.Fatalf("second read: %v", err)
+ }
+ if got := counter.Load(); got != 2 {
+ t.Fatalf("expected 2 file reads, got %d", got)
+ }
+}
+
+func TestFederatedAssertionPropagatesReadError(t *testing.T) {
+ resetFederatedAssertionCache()
+ wifReadFile = func(string) ([]byte, error) { return nil, errors.New("boom") }
+ t.Cleanup(func() { wifReadFile = osReadFileForTests })
+
+ t.Setenv(AzureFederatedTokenFileEnv, "/fake/path")
+ if _, err := FederatedAssertion(); err == nil {
+ t.Fatal("expected error from underlying read")
+ }
+}
+
+// osReadFileForTests restores the real os.ReadFile after each test that
+// monkey-patches wifReadFile. Defined here to avoid an import-cycle.
+var osReadFileForTests = func(name string) ([]byte, error) {
+ return nil, errors.New("real os.ReadFile not stubbed in this test")
+}
diff --git a/backend/server/api/api.go b/backend/server/api/api.go
index d7a39071d..8834c4ecf 100644
--- a/backend/server/api/api.go
+++ b/backend/server/api/api.go
@@ -35,6 +35,7 @@ import (
"github.com/apache/incubator-devlake/core/errors"
"github.com/apache/incubator-devlake/core/plugin"
"github.com/apache/incubator-devlake/impls/logruslog"
+ "github.com/apache/incubator-devlake/server/api/auth"
_ "github.com/apache/incubator-devlake/server/api/docs"
"github.com/apache/incubator-devlake/server/api/ping"
"github.com/apache/incubator-devlake/server/api/shared"
@@ -53,9 +54,9 @@ const DB_MIGRATING = `Database migration is in progress. Please wait until it is
var basicRes context.BasicRes
func Init() {
- // Initialize services
services.Init()
basicRes = services.GetBasicRes()
+ auth.Init(basicRes)
}
func InjectCustomService(pipelineNotifier services.PipelineNotificationService, projectService services.ProjectService) errors.Error {
@@ -104,9 +105,14 @@ func CreateApiServer() *gin.Engine {
router.GET("/health", ping.Health)
router.GET("/version", version.Get)
- // Api keys
+ // Auth chain order matters: REST API key first (its own short-circuit),
+ // then OIDC session, then oauth2-proxy header (only sets USER if not yet
+ // set), then the terminal 401 gate, finally CSRF on unsafe methods.
router.Use(RestAuthentication(router, basicRes))
+ router.Use(auth.OIDCAuthentication())
router.Use(OAuth2ProxyAuthentication(basicRes))
+ router.Use(auth.RequireAuth())
+ router.Use(auth.CSRFProtect())
return router
}
diff --git a/backend/server/api/auth/auth.go b/backend/server/api/auth/auth.go
new file mode 100644
index 000000000..e1012083e
--- /dev/null
+++ b/backend/server/api/auth/auth.go
@@ -0,0 +1,528 @@
+/*
+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 auth implements user-facing OIDC login for DevLake. Supports any
+// number of OIDC providers configured via OIDC_PROVIDERS + per-provider env
+// vars; routes /auth/login?provider=<name> to the corresponding IdP.
+package auth
+
+import (
+ stdctx "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+ "golang.org/x/oauth2"
+
+ corectx "github.com/apache/incubator-devlake/core/context"
+ "github.com/apache/incubator-devlake/core/dal"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/log"
+ "github.com/apache/incubator-devlake/helpers/oidchelper"
+ "github.com/apache/incubator-devlake/server/api/shared"
+)
+
+// Auth-related route paths, defined in one place so router registration and
+// the middleware whitelist cannot drift.
+const (
+ PathMethods = "/auth/methods"
+ PathLogin = "/auth/login"
+ PathCallback = "/auth/callback"
+ PathLogout = "/auth/logout"
+ PathUserInfo = "/auth/userinfo"
+)
+
+// lastSeenThrottle bounds DB writes to one per-jti per window. Tracking
+// "last activity" doesn't need per-request precision.
+const lastSeenThrottle = 5 * time.Minute
+
+// Service holds everything the auth handlers and middlewares need. It is the
+// unit of testability for this package: tests build one with stub deps and
+// drive the gin handlers directly.
+type Service struct {
+ cfg *oidchelper.Config
+ providers map[string]*oidchelper.Provider
+ logger log.Logger
+ db dal.Dal
+ revoked *revocationCache
+
+ lastSeenMu sync.Mutex
+ lastSeen map[string]time.Time
+}
+
+// defaultService is populated by Init and backs the package-level handler /
+// middleware wrappers. Tests that need isolation should construct their own
+// *Service via NewService.
+var (
+ defaultService *Service
+ initOnce sync.Once
+)
+
+// Init builds the default Service from env config and starts the background
+// loops. Panics if AUTH_ENABLED=true but the config is incomplete.
+func Init(basicRes corectx.BasicRes) {
+ initOnce.Do(func() {
+ s, err := NewService(stdctx.Background(), basicRes)
+ if err != nil {
+ panic(fmt.Errorf("auth init: %w", err))
+ }
+ defaultService = s
+ })
+}
+
+// NewService is the testable constructor. ctx governs the lifetime of the
+// revocation refresher and session cleanup goroutines.
+func NewService(ctx stdctx.Context, basicRes corectx.BasicRes) (*Service, error) {
+ cfg, err := oidchelper.LoadConfig(basicRes)
+ if err != nil {
+ return nil, err
+ }
+ s := &Service{
+ cfg: cfg,
+ providers: map[string]*oidchelper.Provider{},
+ logger: basicRes.GetLogger(),
+ db: basicRes.GetDal(),
+ revoked: newRevocationCache(),
+ lastSeen: map[string]time.Time{},
+ }
+ if cfg.AuthEnabled {
+ startRefresher(ctx, s.revoked, s.db, s.logger)
+ startSessionCleanup(ctx, s.db, s.logger)
+ }
+ if cfg.OIDCEnabled {
+ for name, pc := range cfg.Providers {
+ s.providers[name] = oidchelper.NewProvider(pc)
+ s.logger.Info("OIDC provider %q enabled (issuer=%s, client=%s)", name, pc.IssuerURL, pc.ClientID)
+ }
+ } else if cfg.AuthEnabled {
+ s.logger.Info("AUTH_ENABLED but OIDC_ENABLED=false: only API-key/proxy auth will work")
+ }
+ return s, nil
+}
+
+func (s *Service) Config() *oidchelper.Config { return s.cfg }
+
+// Config returns the default service's config. Nil before Init has run.
+func Config() *oidchelper.Config {
+ if defaultService == nil {
+ return nil
+ }
+ return defaultService.cfg
+}
+
+type ProviderInfo struct {
+ Name string `json:"name"`
+ DisplayName string `json:"displayName"`
+ LoginURL string `json:"loginUrl"`
+}
+
+type Methods struct {
+ Providers []ProviderInfo `json:"providers,omitempty"`
+ APIKey *APIKey `json:"apiKey,omitempty"`
+}
+
+type APIKey struct {
+ Enabled bool `json:"enabled"`
+}
+
+func GetMethods(c *gin.Context) { defaultService.GetMethods(c) }
+
+// @Summary List enabled login methods
+// @Tags framework/auth
+// @Success 200 {object} Methods
+// @Router /auth/methods [get]
+func (s *Service) GetMethods(c *gin.Context) {
+ out := Methods{APIKey: &APIKey{Enabled: true}}
+ if s.cfg != nil && s.cfg.OIDCEnabled {
+ for _, name := range s.cfg.ProviderNames() {
+ pc := s.cfg.Providers[name]
+ out.Providers = append(out.Providers, ProviderInfo{
+ Name: name,
+ DisplayName: pc.DisplayName,
+ LoginURL: PathLogin + "?provider=" + url.QueryEscape(name),
+ })
+ }
+ }
+ shared.ApiOutputSuccess(c, out, http.StatusOK)
+}
+
+func LoginInit(c *gin.Context) { defaultService.LoginInit(c) }
+
+// @Summary Begin OIDC login
+// @Tags framework/auth
+// @Param provider query string false "provider name (required when more than one is configured)"
+// @Param return_url query string false "where to redirect after login"
+// @Success 303
+// @Router /auth/login [get]
+func (s *Service) LoginInit(c *gin.Context) {
+ if !s.ensureOIDC(c) {
+ return
+ }
+ name, p, ok := s.pickProvider(c, c.Query("provider"))
+ if !ok {
+ return
+ }
+ returnURL := safeReturnURL(c.Query("return_url"))
+
+ verifier, err := newPKCEVerifier()
+ if err != nil {
+ fail(c, http.StatusInternalServerError, "pkce generate", err)
+ return
+ }
+ nonce, err := oidchelper.NewNonce()
+ if err != nil {
+ fail(c, http.StatusInternalServerError, "state nonce", err)
+ return
+ }
+ encoded, err := oidchelper.EncodeState(s.cfg.SessionSecret, &oidchelper.StatePayload{
+ Provider: name,
+ Nonce: nonce,
+ ReturnURL: returnURL,
+ PKCEVerifier: verifier,
+ IssuedAt: time.Now(),
+ })
+ if err != nil {
+ fail(c, http.StatusInternalServerError, "state encode", err)
+ return
+ }
+ oidchelper.SetStateCookie(c, s.cfg, encoded)
+
+ oa, err := p.OAuth2Config(c.Request.Context())
+ if err != nil {
+ fail(c, http.StatusBadGateway, "oauth2 config", err)
+ return
+ }
+ authURL := oa.AuthCodeURL(nonce,
+ oauth2.SetAuthURLParam("code_challenge", pkceChallenge(verifier)),
+ oauth2.SetAuthURLParam("code_challenge_method", "S256"),
+ )
+ c.Redirect(http.StatusSeeOther, authURL)
+}
+
+func Callback(c *gin.Context) { defaultService.Callback(c) }
+
+// @Summary OIDC callback
+// @Tags framework/auth
+// @Success 303
+// @Router /auth/callback [get]
+func (s *Service) Callback(c *gin.Context) {
+ if !s.ensureOIDC(c) {
+ return
+ }
+
+ encoded, err := c.Cookie(oidchelper.StateCookieName)
+ if err != nil || encoded == "" {
+ fail(c, http.StatusBadRequest, "missing state cookie", err)
+ return
+ }
+ oidchelper.ClearStateCookie(c, s.cfg)
+
+ state, err := oidchelper.DecodeState(s.cfg.SessionSecret, encoded)
+ if err != nil {
+ fail(c, http.StatusBadRequest, "state decode", err)
+ return
+ }
+ // Constant-time compare even though the nonce is non-secret. Keeps the
+ // auth path free of timing-leak audit questions.
+ if subtle.ConstantTimeCompare([]byte(c.Query("state")), []byte(state.Nonce)) != 1 {
+ fail(c, http.StatusBadRequest, "state mismatch", nil)
+ return
+ }
+ p, ok := s.providers[state.Provider]
+ if !ok {
+ fail(c, http.StatusBadRequest, "unknown provider in state: "+state.Provider, nil)
+ return
+ }
+ code := c.Query("code")
+ if code == "" {
+ if msg := c.Query("error"); msg != "" {
+ fail(c, http.StatusBadRequest, "idp error: "+msg+" "+c.Query("error_description"), nil)
+ return
+ }
+ fail(c, http.StatusBadRequest, "missing authorization code", nil)
+ return
+ }
+
+ oa, err := p.OAuth2Config(c.Request.Context())
+ if err != nil {
+ fail(c, http.StatusBadGateway, "oauth2 config", err)
+ return
+ }
+ exchangeOpts := []oauth2.AuthCodeOption{
+ oauth2.SetAuthURLParam("code_verifier", state.PKCEVerifier),
+ }
+ if pc := s.cfg.Providers[state.Provider]; pc != nil && pc.UseWorkloadIdentity {
+ assertion, werr := oidchelper.FederatedAssertion()
+ if werr != nil {
+ fail(c, http.StatusInternalServerError, "workload identity assertion", werr)
+ return
+ }
+ exchangeOpts = append(exchangeOpts,
+ oauth2.SetAuthURLParam("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
+ oauth2.SetAuthURLParam("client_assertion", assertion),
+ )
+ }
+ tok, err := oa.Exchange(c.Request.Context(), code, exchangeOpts...)
+ if err != nil {
+ fail(c, http.StatusBadGateway, "code exchange", err)
+ return
+ }
+ rawID, _ := tok.Extra("id_token").(string)
+ if rawID == "" {
+ fail(c, http.StatusBadGateway, "id_token missing from token response", nil)
+ return
+ }
+ idTok, err := p.VerifyIDToken(c.Request.Context(), rawID)
+ if err != nil {
+ fail(c, http.StatusUnauthorized, "id_token verify", err)
+ return
+ }
+
+ sub, email, name, err := extractUser(idTok)
+ if err != nil {
+ fail(c, http.StatusBadGateway, "extract claims", err)
+ return
+ }
+ jti := uuid.NewString()
+ jwt, expiresAt, err := oidchelper.IssueSession(s.cfg, jti, state.Provider, sub, email, name)
+ if err != nil {
+ fail(c, http.StatusInternalServerError, "issue session", err)
+ return
+ }
+ now := time.Now()
+ if dbErr := CreateSession(s.db, &AuthSession{
+ Jti: jti,
+ Sub: sub,
+ Email: email,
+ Name: name,
+ IssuedAt: now,
+ ExpiresAt: expiresAt,
+ LastSeenAt: now,
+ }); dbErr != nil {
+ fail(c, http.StatusInternalServerError, "persist session", dbErr)
+ return
+ }
+ csrf, err := oidchelper.NewCSRFToken()
+ if err != nil {
+ fail(c, http.StatusInternalServerError, "csrf token", err)
+ return
+ }
+ oidchelper.SetSessionCookie(c, s.cfg, jwt)
+ oidchelper.SetCSRFCookie(c, s.cfg, csrf)
+ s.logger.Info("oidc login: provider=%s sub=%s email=%s jti=%s", state.Provider, sub, email, jti)
+
+ c.Redirect(http.StatusSeeOther, state.ReturnURL)
+}
+
+type logoutResponse struct {
+ OK bool `json:"ok"`
+ LogoutURL string `json:"logoutUrl,omitempty"`
+}
+
+func Logout(c *gin.Context) { defaultService.Logout(c) }
+
+// @Summary Logout
+// @Tags framework/auth
+// @Success 200 {object} logoutResponse
+// @Router /auth/logout [post]
+func (s *Service) Logout(c *gin.Context) {
+ if s.cfg == nil || !s.cfg.AuthEnabled {
+ shared.ApiOutputSuccess(c, logoutResponse{OK: true}, http.StatusOK)
+ return
+ }
+
+ var sessionProvider string
+ if raw, err := c.Cookie(oidchelper.SessionCookieName); err == nil && raw != "" {
+ if claims, err := oidchelper.ParseSession(s.cfg.SessionSecret, raw); err == nil && claims.ID != "" {
+ sessionProvider = claims.Provider
+ if err := RevokeSession(s.db, claims.ID); err != nil {
+ s.logger.Error(err, "auth: revoke session row")
+ }
+ s.revoked.Add(claims.ID)
+ }
+ }
+ oidchelper.ClearSessionCookie(c, s.cfg)
+ oidchelper.ClearCSRFCookie(c, s.cfg)
+
+ out := logoutResponse{OK: true}
+ if s.cfg.OIDCEnabled && s.cfg.LogoutRedirect && sessionProvider != "" {
+ if p, ok := s.providers[sessionProvider]; ok {
+ if u, err := p.EndSessionURL(c.Request.Context()); err == nil && u != "" {
+ out.LogoutURL = u
+ }
+ }
+ }
+ shared.ApiOutputSuccess(c, out, http.StatusOK)
+}
+
+type userInfoResponse struct {
+ Authenticated bool `json:"authenticated"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+}
+
+func UserInfo(c *gin.Context) { defaultService.UserInfo(c) }
+
+// UserInfo always returns 200 so calling it does not trigger the UI's
+// 401-redirect loop; the `authenticated` field discriminates.
+//
+// @Summary Current user
+// @Tags framework/auth
+// @Success 200 {object} userInfoResponse
+// @Router /auth/userinfo [get]
+func (s *Service) UserInfo(c *gin.Context) {
+ u, ok := shared.GetUser(c)
+ if !ok || u == nil {
+ shared.ApiOutputSuccess(c, userInfoResponse{Authenticated: false}, http.StatusOK)
+ return
+ }
+ shared.ApiOutputSuccess(c, userInfoResponse{
+ Authenticated: true,
+ Name: u.Name,
+ Email: u.Email,
+ }, http.StatusOK)
+}
+
+// pickProvider resolves the requested provider name. Empty names are allowed
+// only when exactly one provider is configured (single-IdP convenience).
+func (s *Service) pickProvider(c *gin.Context, requested string) (string, *oidchelper.Provider, bool) {
+ name := strings.ToLower(strings.TrimSpace(requested))
+ if name == "" {
+ if len(s.providers) == 1 {
+ for n := range s.providers {
+ name = n
+ }
+ } else {
+ fail(c, http.StatusBadRequest, "provider query param required (multiple configured)", nil)
+ return "", nil, false
+ }
+ }
+ p, ok := s.providers[name]
+ if !ok {
+ fail(c, http.StatusBadRequest, "unknown provider: "+name, nil)
+ return "", nil, false
+ }
+ return name, p, true
+}
+
+func (s *Service) ensureOIDC(c *gin.Context) bool {
+ if s.cfg == nil || !s.cfg.OIDCEnabled || len(s.providers) == 0 {
+ shared.ApiOutputError(c, errors.HttpStatus(http.StatusServiceUnavailable).New("OIDC is not enabled"))
+ return false
+ }
+ return true
+}
+
+// bumpLastSeen records that jti was used. The DB write happens at most once
+// per lastSeenThrottle per jti, off the request path.
+func (s *Service) bumpLastSeen(jti string) {
+ now := time.Now()
+ s.lastSeenMu.Lock()
+ if last, ok := s.lastSeen[jti]; ok && now.Sub(last) < lastSeenThrottle {
+ s.lastSeenMu.Unlock()
+ return
+ }
+ s.lastSeen[jti] = now
+ s.lastSeenMu.Unlock()
+
+ go func() {
+ if err := UpdateLastSeen(s.db, jti, now); err != nil {
+ s.logger.Debug("auth: bump last_seen jti=%s: %v", jti, err)
+ }
+ }()
+}
+
+func fail(c *gin.Context, status int, msg string, cause error) {
+ wrapped := errors.HttpStatus(status).New(msg)
+ if cause != nil {
+ wrapped = errors.HttpStatus(status).Wrap(cause, msg)
+ }
+ shared.ApiOutputError(c, wrapped)
+}
+
+// safeReturnURL prevents open-redirect by stripping anything that doesn't
+// look like a same-origin path. The // and /\ prefixes get a fast path
+// because some browsers normalize backslash → slash and would treat
+// /\evil.com as a protocol-relative URL.
+func safeReturnURL(raw string) string {
+ if raw == "" {
+ return "/"
+ }
+ if strings.HasPrefix(raw, "//") || strings.HasPrefix(raw, `/\`) {
+ return "/"
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ return "/"
+ }
+ if u.IsAbs() || u.Host != "" || !strings.HasPrefix(u.Path, "/") {
+ return "/"
+ }
+ return u.RequestURI()
+}
+
+// extractUser pulls user identity out of the verified ID token. Email is
+// taken strictly from the `email` claim; we never coerce a username into
+// the email column. Display name falls back to preferred_username, then
+// email, so the UI always has *something* to render.
+func extractUser(tok *oidc.IDToken) (sub, email, name string, err error) {
+ var claims struct {
+ Sub string `json:"sub"`
+ Email string `json:"email"`
+ PreferredUsername string `json:"preferred_username"`
+ Name string `json:"name"`
+ }
+ if err := tok.Claims(&claims); err != nil {
+ return "", "", "", err
+ }
+ if claims.Sub == "" {
+ return "", "", "", fmt.Errorf("id_token missing sub claim")
+ }
+ name = claims.Name
+ if name == "" {
+ name = claims.PreferredUsername
+ }
+ if name == "" {
+ name = claims.Email
+ }
+ return claims.Sub, claims.Email, name, nil
+}
+
+func pkceChallenge(verifier string) string {
+ sum := sha256.Sum256([]byte(verifier))
+ return base64.RawURLEncoding.EncodeToString(sum[:])
+}
+
+// newPKCEVerifier returns a 64-char random URL-safe string per RFC 7636.
+func newPKCEVerifier() (string, error) {
+ b := make([]byte, 48)
+ if _, err := io.ReadFull(rand.Reader, b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
diff --git a/backend/server/api/auth/auth_test.go b/backend/server/api/auth/auth_test.go
new file mode 100644
index 000000000..75d09ab47
--- /dev/null
+++ b/backend/server/api/auth/auth_test.go
@@ -0,0 +1,51 @@
+/*
+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 auth
+
+import "testing"
+
+func TestSafeReturnURL(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want string
+ }{
+ {"empty", "", "/"},
+ {"root", "/", "/"},
+ {"normal path", "/projects", "/projects"},
+ {"path with query", "/projects?tab=2", "/projects?tab=2"},
+ {"path with fragment kept", "/projects#section", "/projects"},
+ {"absolute http URL", "http://evil.com/x", "/"},
+ {"absolute https URL", "https://evil.com/x", "/"},
+ {"protocol-relative", "//evil.com", "/"},
+ {"protocol-relative with path", "//evil.com/x", "/"},
+ {"backslash variant", `/\evil.com`, "/"},
+ {"backslash variant with path", `/\evil.com/x`, "/"},
+ {"missing leading slash", "projects", "/"},
+ {"javascript scheme", "javascript:alert(1)", "/"},
+ {"data scheme", "data:text/html,x", "/"},
+ {"unparseable", "://", "/"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := safeReturnURL(tc.in); got != tc.want {
+ t.Errorf("safeReturnURL(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/backend/server/api/auth/cleanup.go b/backend/server/api/auth/cleanup.go
new file mode 100644
index 000000000..3dcaf7421
--- /dev/null
+++ b/backend/server/api/auth/cleanup.go
@@ -0,0 +1,57 @@
+/*
+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 auth
+
+import (
+ "context"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/dal"
+ "github.com/apache/incubator-devlake/core/log"
+)
+
+const (
+ sessionCleanupInterval = 24 * time.Hour
+ // Keep expired rows around as a soft audit trail before pruning. 30d
+ // covers any reasonable "who logged in last month" question without
+ // letting the table grow unbounded.
+ sessionRetentionAfterExpiry = 30 * 24 * time.Hour
+)
+
+// startSessionCleanup deletes long-expired session rows on a daily timer.
+// First sweep runs after one interval, not at boot, so deploys don't pay
+// the cleanup cost during the noisy startup window.
+func startSessionCleanup(ctx context.Context, db dal.Dal, logger log.Logger) {
+ go func() {
+ t := time.NewTicker(sessionCleanupInterval)
+ defer t.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ cutoff := time.Now().Add(-sessionRetentionAfterExpiry)
+ if err := db.Delete(&AuthSession{}, dal.Where("expires_at < ?", cutoff)); err != nil {
+ logger.Error(err, "auth: session cleanup")
+ continue
+ }
+ logger.Info("auth: pruned auth_sessions rows expired before %s", cutoff.Format(time.RFC3339))
+ }
+ }
+ }()
+}
diff --git a/backend/server/api/auth/handlers_test.go b/backend/server/api/auth/handlers_test.go
new file mode 100644
index 000000000..7067fa978
--- /dev/null
+++ b/backend/server/api/auth/handlers_test.go
@@ -0,0 +1,426 @@
+/*
+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 auth
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/base64"
+ "encoding/json"
+ "math/big"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/stretchr/testify/mock"
+
+ "github.com/apache/incubator-devlake/core/dal"
+ "github.com/apache/incubator-devlake/helpers/oidchelper"
+ "github.com/apache/incubator-devlake/impls/logruslog"
+ mockdal "github.com/apache/incubator-devlake/mocks/core/dal"
+)
+
+// fakeIdP is a minimal OIDC provider sufficient for go-oidc discovery,
+// token exchange, and JWKS verification.
+type fakeIdP struct {
+ server *httptest.Server
+ key *rsa.PrivateKey
+ keyID string
+ issuer string
+
+ mu sync.Mutex
+ lastCode string
+ subject string
+ email string
+ name string
+}
+
+func newFakeIdP(t *testing.T) *fakeIdP {
+ t.Helper()
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatalf("rsa.GenerateKey: %v", err)
+ }
+ idp := &fakeIdP{
+ key: key,
+ keyID: "test-key",
+ subject: "user-123",
+ email: "alice@example.com",
+ name: "Alice",
+ }
+ mux := http.NewServeMux()
+ mux.HandleFunc("/.well-known/openid-configuration", idp.handleDiscovery)
+ mux.HandleFunc("/jwks", idp.handleJWKS)
+ mux.HandleFunc("/token", idp.handleToken)
+ mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "authorize endpoint not exercised by these tests", http.StatusNotImplemented)
+ })
+ mux.HandleFunc("/logout", func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+ idp.server = httptest.NewServer(mux)
+ idp.issuer = idp.server.URL
+ t.Cleanup(idp.server.Close)
+ return idp
+}
+
+func (f *fakeIdP) handleDiscovery(w http.ResponseWriter, _ *http.Request) {
+ doc := map[string]any{
+ "issuer": f.issuer,
+ "authorization_endpoint": f.issuer + "/authorize",
+ "token_endpoint": f.issuer + "/token",
+ "jwks_uri": f.issuer + "/jwks",
+ "end_session_endpoint": f.issuer + "/logout",
+ "response_types_supported": []string{"code"},
+ "subject_types_supported": []string{"public"},
+ "id_token_signing_alg_values_supported": []string{"RS256"},
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(doc)
+}
+
+func (f *fakeIdP) handleJWKS(w http.ResponseWriter, _ *http.Request) {
+ pub := f.key.PublicKey
+ doc := map[string]any{
+ "keys": []map[string]string{{
+ "kty": "RSA",
+ "use": "sig",
+ "kid": f.keyID,
+ "alg": "RS256",
+ "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
+ "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
+ }},
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(doc)
+}
+
+func (f *fakeIdP) handleToken(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ code := r.FormValue("code")
+ f.mu.Lock()
+ f.lastCode = code
+ f.mu.Unlock()
+
+ // oauth2.Config.Exchange sends client credentials via HTTP Basic by
+ // default. Fall back to the form for clients that send them there.
+ clientID := r.FormValue("client_id")
+ if u, _, ok := r.BasicAuth(); ok {
+ clientID = u
+ }
+
+ tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
+ "iss": f.issuer,
+ "aud": clientID,
+ "sub": f.subject,
+ "email": f.email,
+ "name": f.name,
+ "iat": time.Now().Unix(),
+ "exp": time.Now().Add(5 * time.Minute).Unix(),
+ })
+ tok.Header["kid"] = f.keyID
+ signed, err := tok.SignedString(f.key)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "access_token": "dummy-access",
+ "token_type": "Bearer",
+ "expires_in": 300,
+ "id_token": signed,
+ })
+}
+
+// newTestService builds a Service backed by the fake IdP and a permissive
+// dal mock that accepts the auth-store writes the handlers issue.
+func newTestService(t *testing.T, idp *fakeIdP) (*Service, *mockdal.Dal) {
+ t.Helper()
+ pc := &oidchelper.ProviderConfig{
+ Name: "test",
+ IssuerURL: idp.issuer,
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ RedirectURL: "http://localhost/auth/callback",
+ Scopes: []string{"openid", "profile", "email"},
+ DisplayName: "Test IdP",
+ }
+ cfg := &oidchelper.Config{
+ AuthEnabled: true,
+ OIDCEnabled: true,
+ Providers: map[string]*oidchelper.ProviderConfig{"test": pc},
+ LogoutRedirect: true,
+ SessionSecret: []byte("test-secret-with-at-least-32-bytes!"),
+ SessionTTL: time.Hour,
+ CookieSecure: false,
+ }
+ db := &mockdal.Dal{}
+ db.On("Create", mock.Anything, mock.Anything).Return(nil)
+ db.On("UpdateColumn", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
+ db.On("All", mock.Anything, mock.Anything).Return(nil)
+ db.On("Delete", mock.Anything, mock.Anything).Return(nil)
+
+ s := &Service{
+ cfg: cfg,
+ providers: map[string]*oidchelper.Provider{"test": oidchelper.NewProvider(pc)},
+ logger: logruslog.Global,
+ db: db,
+ revoked: newRevocationCache(),
+ lastSeen: map[string]time.Time{},
+ }
+ return s, db
+}
+
+func newTestRouter(s *Service) *gin.Engine {
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(s.OIDCAuthentication())
+ r.Use(s.RequireAuth())
+ r.Use(s.CSRFProtect())
+ r.GET(PathLogin, s.LoginInit)
+ r.GET(PathCallback, s.Callback)
+ r.GET(PathUserInfo, s.UserInfo)
+ r.POST(PathLogout, s.Logout)
+ return r
+}
+
+// extractCookie pulls a Set-Cookie value by name from a recorded response.
+func extractCookie(t *testing.T, resp *http.Response, name string) *http.Cookie {
+ t.Helper()
+ for _, c := range resp.Cookies() {
+ if c.Name == name {
+ return c
+ }
+ }
+ t.Fatalf("cookie %q not found in response", name)
+ return nil
+}
+
+// stateNonceFromCookie decodes the encrypted state cookie back into the
+// nonce, which the test then echoes as the OIDC `state` query parameter.
+func stateNonceFromCookie(t *testing.T, secret []byte, cookieValue string) string {
+ t.Helper()
+ state, err := oidchelper.DecodeState(secret, cookieValue)
+ if err != nil {
+ t.Fatalf("decode state cookie: %v", err)
+ }
+ return state.Nonce
+}
+
+func TestLoginInitSetsStateCookieAndRedirects(t *testing.T) {
+ idp := newFakeIdP(t)
+ s, _ := newTestService(t, idp)
+ r := newTestRouter(s)
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, PathLogin+"?provider=test", nil)
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("expected 303, got %d: %s", w.Code, w.Body.String())
+ }
+ loc := w.Header().Get("Location")
+ if !strings.Contains(loc, "code_challenge=") || !strings.Contains(loc, "code_challenge_method=S256") {
+ t.Fatalf("expected PKCE params on auth URL, got %q", loc)
+ }
+ resp := w.Result()
+ defer resp.Body.Close()
+ if c := extractCookie(t, resp, oidchelper.StateCookieName); c.Value == "" {
+ t.Fatal("state cookie was empty")
+ }
+}
+
+func TestFullLoginCallbackFlow(t *testing.T) {
+ idp := newFakeIdP(t)
+ s, db := newTestService(t, idp)
+ r := newTestRouter(s)
+
+ // 1. /auth/login: capture the state cookie.
+ loginW := httptest.NewRecorder()
+ r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, PathLogin+"?provider=test", nil))
+ if loginW.Code != http.StatusSeeOther {
+ t.Fatalf("login: expected 303, got %d", loginW.Code)
+ }
+ stateCookie := extractCookie(t, loginW.Result(), oidchelper.StateCookieName)
+ nonce := stateNonceFromCookie(t, s.cfg.SessionSecret, stateCookie.Value)
+
+ // 2. /auth/callback: must succeed and set session + csrf cookies.
+ cbReq := httptest.NewRequest(http.MethodGet,
+ PathCallback+"?code=fake-code&state="+url.QueryEscape(nonce), nil)
+ cbReq.AddCookie(stateCookie)
+ cbW := httptest.NewRecorder()
+ r.ServeHTTP(cbW, cbReq)
+ if cbW.Code != http.StatusSeeOther {
+ t.Fatalf("callback: expected 303, got %d body=%s", cbW.Code, cbW.Body.String())
+ }
+ sessionCookie := extractCookie(t, cbW.Result(), oidchelper.SessionCookieName)
+ csrfCookie := extractCookie(t, cbW.Result(), oidchelper.CSRFCookieName)
+ if sessionCookie.Value == "" || csrfCookie.Value == "" {
+ t.Fatal("expected session and csrf cookies to be set")
+ }
+ db.AssertCalled(t, "Create", mock.Anything, mock.Anything)
+
+ // 3. /auth/userinfo with the session cookie should report authenticated.
+ uiReq := httptest.NewRequest(http.MethodGet, PathUserInfo, nil)
+ uiReq.AddCookie(sessionCookie)
+ uiW := httptest.NewRecorder()
+ r.ServeHTTP(uiW, uiReq)
+ if uiW.Code != http.StatusOK {
+ t.Fatalf("userinfo: expected 200, got %d", uiW.Code)
+ }
+ var userResp userInfoResponse
+ if err := json.Unmarshal(uiW.Body.Bytes(), &userResp); err != nil {
+ t.Fatalf("decode userinfo: %v: %s", err, uiW.Body.String())
+ }
+ if !userResp.Authenticated || userResp.Email != idp.email {
+ t.Fatalf("unexpected userinfo: %+v body=%s", userResp, uiW.Body.String())
+ }
+
+ // 4. /auth/logout with session + csrf header should succeed and revoke.
+ logoutReq := httptest.NewRequest(http.MethodPost, PathLogout, nil)
+ logoutReq.AddCookie(sessionCookie)
+ logoutReq.AddCookie(csrfCookie)
+ logoutReq.Header.Set(oidchelper.CSRFHeaderName, csrfCookie.Value)
+ logoutW := httptest.NewRecorder()
+ r.ServeHTTP(logoutW, logoutReq)
+ if logoutW.Code != http.StatusOK {
+ t.Fatalf("logout: expected 200, got %d body=%s", logoutW.Code, logoutW.Body.String())
+ }
+ db.AssertCalled(t, "UpdateColumn", mock.Anything, "revoked_at", mock.Anything, mock.Anything)
+
+ // 5. The same session cookie now belongs to a revoked jti; userinfo
+ // should report not authenticated.
+ uiReq2 := httptest.NewRequest(http.MethodGet, PathUserInfo, nil)
+ uiReq2.AddCookie(sessionCookie)
+ uiW2 := httptest.NewRecorder()
+ r.ServeHTTP(uiW2, uiReq2)
+ var userResp2 userInfoResponse
+ if err := json.Unmarshal(uiW2.Body.Bytes(), &userResp2); err != nil {
+ t.Fatalf("decode userinfo (revoked): %v", err)
+ }
+ if userResp2.Authenticated {
+ t.Fatal("expected revoked session to no longer be authenticated")
+ }
+}
+
+func TestCallbackRejectsStateMismatch(t *testing.T) {
+ idp := newFakeIdP(t)
+ s, _ := newTestService(t, idp)
+ r := newTestRouter(s)
+
+ loginW := httptest.NewRecorder()
+ r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, PathLogin+"?provider=test", nil))
+ stateCookie := extractCookie(t, loginW.Result(), oidchelper.StateCookieName)
+
+ cbReq := httptest.NewRequest(http.MethodGet, PathCallback+"?code=x&state=not-the-real-nonce", nil)
+ cbReq.AddCookie(stateCookie)
+ cbW := httptest.NewRecorder()
+ r.ServeHTTP(cbW, cbReq)
+ if cbW.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 on state mismatch, got %d", cbW.Code)
+ }
+ if !strings.Contains(cbW.Body.String(), "state mismatch") {
+ t.Fatalf("expected state mismatch in body, got %s", cbW.Body.String())
+ }
+}
+
+func TestCallbackRejectsMissingStateCookie(t *testing.T) {
+ idp := newFakeIdP(t)
+ s, _ := newTestService(t, idp)
+ r := newTestRouter(s)
+
+ cbReq := httptest.NewRequest(http.MethodGet, PathCallback+"?code=x&state=anything", nil)
+ cbW := httptest.NewRecorder()
+ r.ServeHTTP(cbW, cbReq)
+ if cbW.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 with missing state cookie, got %d", cbW.Code)
+ }
+ if !strings.Contains(cbW.Body.String(), "missing state cookie") {
+ t.Fatalf("expected missing state cookie in body, got %s", cbW.Body.String())
+ }
+}
+
+func TestCSRFRequiredOnUnsafeMethod(t *testing.T) {
+ idp := newFakeIdP(t)
+ s, _ := newTestService(t, idp)
+ r := newTestRouter(s)
+ r.POST("/some-mutation", func(c *gin.Context) { c.Status(http.StatusNoContent) })
+
+ // Issue a session via the fake-callback so we have valid cookies.
+ loginW := httptest.NewRecorder()
+ r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, PathLogin+"?provider=test", nil))
+ stateCookie := extractCookie(t, loginW.Result(), oidchelper.StateCookieName)
+ nonce := stateNonceFromCookie(t, s.cfg.SessionSecret, stateCookie.Value)
+
+ cbReq := httptest.NewRequest(http.MethodGet,
+ PathCallback+"?code=c&state="+url.QueryEscape(nonce), nil)
+ cbReq.AddCookie(stateCookie)
+ cbW := httptest.NewRecorder()
+ r.ServeHTTP(cbW, cbReq)
+ sessionCookie := extractCookie(t, cbW.Result(), oidchelper.SessionCookieName)
+ csrfCookie := extractCookie(t, cbW.Result(), oidchelper.CSRFCookieName)
+
+ // POST without the CSRF header is rejected.
+ bad := httptest.NewRequest(http.MethodPost, "/some-mutation", nil)
+ bad.AddCookie(sessionCookie)
+ bad.AddCookie(csrfCookie)
+ badW := httptest.NewRecorder()
+ r.ServeHTTP(badW, bad)
+ if badW.Code != http.StatusForbidden {
+ t.Fatalf("expected 403 without csrf header, got %d", badW.Code)
+ }
+
+ // POST with a matching header passes.
+ good := httptest.NewRequest(http.MethodPost, "/some-mutation", nil)
+ good.AddCookie(sessionCookie)
+ good.AddCookie(csrfCookie)
+ good.Header.Set(oidchelper.CSRFHeaderName, csrfCookie.Value)
+ goodW := httptest.NewRecorder()
+ r.ServeHTTP(goodW, good)
+ if goodW.Code != http.StatusNoContent {
+ t.Fatalf("expected 204 with csrf header, got %d body=%s", goodW.Code, goodW.Body.String())
+ }
+}
+
+func TestLogoutPublicWhenNoSession(t *testing.T) {
+ idp := newFakeIdP(t)
+ s, _ := newTestService(t, idp)
+ r := newTestRouter(s)
+
+ // No session cookie, no csrf header. publicPaths should let it through
+ // and the handler should respond 200.
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, PathLogout, nil))
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected logout-without-session to return 200, got %d body=%s", w.Code, w.Body.String())
+ }
+}
+
+// Compile-time assertion that the testify mock satisfies the dal.Dal
+// interface so the tests catch any new method that gets added.
+var _ dal.Dal = (*mockdal.Dal)(nil)
diff --git a/backend/server/api/auth/middleware.go b/backend/server/api/auth/middleware.go
new file mode 100644
index 000000000..ef39cabf7
--- /dev/null
+++ b/backend/server/api/auth/middleware.go
@@ -0,0 +1,165 @@
+/*
+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 auth
+
+import (
+ "crypto/subtle"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/apache/incubator-devlake/core/models/common"
+ "github.com/apache/incubator-devlake/helpers/oidchelper"
+ "github.com/apache/incubator-devlake/server/api/shared"
+)
+
+// publicPaths is the set of routes reachable without authentication.
+// /auth/userinfo and /auth/logout are public so the UI can poll identity
+// and clear its session even when the cookie has lapsed; both handlers
+// short-circuit gracefully when no user is set.
+var publicPaths = map[string]struct{}{
+ "/ping": {},
+ "/ready": {},
+ "/health": {},
+ "/version": {},
+ "/proceed-db-migration": {},
+ PathMethods: {},
+ PathLogin: {},
+ PathCallback: {},
+ PathLogout: {},
+ PathUserInfo: {},
+}
+
+func OIDCAuthentication() gin.HandlerFunc { return defaultService.OIDCAuthentication() }
+
+func RequireAuth() gin.HandlerFunc { return defaultService.RequireAuth() }
+
+func CSRFProtect() gin.HandlerFunc { return defaultService.CSRFProtect() }
+
+// OIDCAuthentication reads the session cookie, verifies the JWT, and sets
+// common.USER on the context. Soft authenticator: invalid/missing cookies
+// pass through and RequireAuth decides whether to reject.
+func (s *Service) OIDCAuthentication() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if s.cfg == nil || !s.cfg.AuthEnabled {
+ c.Next()
+ return
+ }
+ if _, ok := shared.GetUser(c); ok {
+ c.Next()
+ return
+ }
+ raw, err := c.Cookie(oidchelper.SessionCookieName)
+ if err != nil || raw == "" {
+ c.Next()
+ return
+ }
+ claims, err := oidchelper.ParseSession(s.cfg.SessionSecret, raw)
+ if err != nil {
+ s.logger.Debug("invalid session cookie: %v", err)
+ oidchelper.ClearSessionCookie(c, s.cfg)
+ c.Next()
+ return
+ }
+ if s.revoked != nil && s.revoked.IsRevoked(claims.ID) {
+ s.logger.Debug("revoked session presented: jti=%s", claims.ID)
+ oidchelper.ClearSessionCookie(c, s.cfg)
+ c.Next()
+ return
+ }
+ c.Set(common.USER, &common.User{
+ Name: claims.Name,
+ Email: claims.Email,
+ })
+ s.bumpLastSeen(claims.ID)
+ c.Next()
+ }
+}
+
+// RequireAuth is the terminal gate. No-op when AUTH_ENABLED=false so existing
+// deployments are unaffected.
+func (s *Service) RequireAuth() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if s.cfg == nil || !s.cfg.AuthEnabled {
+ c.Next()
+ return
+ }
+ if isPublicPath(c.Request.URL.Path) {
+ c.Next()
+ return
+ }
+ if _, ok := shared.GetUser(c); ok {
+ c.Next()
+ return
+ }
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
+ "success": false,
+ "message": "unauthorized",
+ })
+ }
+}
+
+func isPublicPath(path string) bool {
+ if _, ok := publicPaths[path]; ok {
+ return true
+ }
+ return strings.HasPrefix(path, "/swagger/")
+}
+
+// CSRFProtect rejects unsafe (POST/PUT/DELETE/PATCH) requests authenticated
+// via the session cookie unless they echo the CSRF cookie back as the
+// X-CSRF-Token header. Other auth methods (API key Bearer, oauth2-proxy
+// header) are not subject to CSRF; they don't ride on ambient cookies.
+//
+// SameSite=Lax already blocks the textbook cross-origin form-POST attack;
+// this is defense-in-depth for shared-parent-domain deployments and any
+// future GET endpoint that is upgraded to a mutation.
+func (s *Service) CSRFProtect() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if s.cfg == nil || !s.cfg.AuthEnabled {
+ c.Next()
+ return
+ }
+ switch c.Request.Method {
+ case http.MethodGet, http.MethodHead, http.MethodOptions:
+ c.Next()
+ return
+ }
+ if isPublicPath(c.Request.URL.Path) {
+ c.Next()
+ return
+ }
+ // CSRF only applies when the caller is authenticating via the session
+ // cookie. Bearer tokens and proxy headers aren't replayable cross-site.
+ if _, err := c.Cookie(oidchelper.SessionCookieName); err != nil {
+ c.Next()
+ return
+ }
+ cookie, err := c.Cookie(oidchelper.CSRFCookieName)
+ header := c.GetHeader(oidchelper.CSRFHeaderName)
+ if err != nil || cookie == "" || header == "" || subtle.ConstantTimeCompare([]byte(cookie), []byte(header)) != 1 {
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
+ "success": false,
+ "message": "csrf token missing or invalid",
+ })
+ return
+ }
+ c.Next()
+ }
+}
diff --git a/backend/server/api/auth/revocation_cache.go b/backend/server/api/auth/revocation_cache.go
new file mode 100644
index 000000000..b704d1bb4
--- /dev/null
+++ b/backend/server/api/auth/revocation_cache.go
@@ -0,0 +1,117 @@
+/*
+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 auth
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/dal"
+ "github.com/apache/incubator-devlake/core/log"
+)
+
+// revocationRefreshInterval is how often the cache reloads the revoked-jti
+// set from the database. Revocations propagate within this window.
+const revocationRefreshInterval = 30 * time.Second
+
+// revocationCache holds the set of revoked session jtis. The MVP keeps it
+// fully in memory because the set is tiny (only sessions that were
+// explicitly revoked, pruned on natural expiry) and per-request DB lookups
+// would otherwise dominate the auth middleware's hot path.
+type revocationCache struct {
+ mu sync.RWMutex
+ revoked map[string]struct{}
+}
+
+func newRevocationCache() *revocationCache {
+ return &revocationCache{revoked: map[string]struct{}{}}
+}
+
+func (r *revocationCache) IsRevoked(jti string) bool {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ _, ok := r.revoked[jti]
+ return ok
+}
+
+func (r *revocationCache) Add(jti string) {
+ r.mu.Lock()
+ r.revoked[jti] = struct{}{}
+ r.mu.Unlock()
+}
+
+// Refresh reloads the revoked set from the DB while preserving any Adds
+// that landed concurrently with the DB read. The race we're guarding:
+// Logout calls RevokeSession then revoked.Add; if Refresh's DB read happens
+// between those two steps, the new map would be missing the jti. Carrying
+// forward "additions since the snapshot" plugs that gap.
+func (r *revocationCache) Refresh(db dal.Dal) error {
+ r.mu.RLock()
+ before := make(map[string]struct{}, len(r.revoked))
+ for j := range r.revoked {
+ before[j] = struct{}{}
+ }
+ r.mu.RUnlock()
+
+ jtis, err := ListRevoked(db)
+ if err != nil {
+ return err
+ }
+
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ next := make(map[string]struct{}, len(jtis))
+ for _, j := range jtis {
+ next[j] = struct{}{}
+ }
+ // Anything in the current set that wasn't in the snapshot was Added
+ // during the DB read; keep it. Anything in both `before` and the
+ // current set but absent from the DB result has expired naturally and
+ // is safe to drop.
+ for j := range r.revoked {
+ if _, wasBefore := before[j]; !wasBefore {
+ next[j] = struct{}{}
+ }
+ }
+ r.revoked = next
+ return nil
+}
+
+// startRefresher loads the cache once synchronously, then refreshes it on a
+// timer. The synchronous first load means we never accept revoked sessions
+// in a startup window.
+func startRefresher(ctx context.Context, cache *revocationCache, db dal.Dal, logger log.Logger) {
+ if err := cache.Refresh(db); err != nil {
+ logger.Error(err, "auth: initial revocation cache load failed")
+ }
+ go func() {
+ t := time.NewTicker(revocationRefreshInterval)
+ defer t.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ if err := cache.Refresh(db); err != nil {
+ logger.Error(err, "auth: revocation cache refresh failed")
+ }
+ }
+ }
+ }()
+}
diff --git a/backend/server/api/auth/revocation_cache_test.go b/backend/server/api/auth/revocation_cache_test.go
new file mode 100644
index 000000000..8b66c2c8d
--- /dev/null
+++ b/backend/server/api/auth/revocation_cache_test.go
@@ -0,0 +1,67 @@
+/*
+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 auth
+
+import (
+ "sync"
+ "testing"
+)
+
+func TestRevocationCacheEmpty(t *testing.T) {
+ c := newRevocationCache()
+ if c.IsRevoked("anything") {
+ t.Fatal("empty cache should never report revoked")
+ }
+}
+
+func TestRevocationCacheAdd(t *testing.T) {
+ c := newRevocationCache()
+ c.Add("jti-1")
+ if !c.IsRevoked("jti-1") {
+ t.Fatal("expected jti-1 to be revoked")
+ }
+ if c.IsRevoked("jti-2") {
+ t.Fatal("jti-2 was never added; should not be revoked")
+ }
+}
+
+func TestRevocationCacheConcurrentReadsAndWrites(t *testing.T) {
+ c := newRevocationCache()
+ var wg sync.WaitGroup
+ for i := 0; i < 100; i++ {
+ wg.Add(2)
+ go func(i int) {
+ defer wg.Done()
+ c.Add(jtiKey(i))
+ }(i)
+ go func(i int) {
+ defer wg.Done()
+ _ = c.IsRevoked(jtiKey(i))
+ }(i)
+ }
+ wg.Wait()
+ for i := 0; i < 100; i++ {
+ if !c.IsRevoked(jtiKey(i)) {
+ t.Fatalf("jti %d missing after concurrent adds", i)
+ }
+ }
+}
+
+func jtiKey(i int) string {
+ return "jti-" + string(rune('a'+i%26)) + string(rune('0'+(i/26)%10))
+}
diff --git a/backend/server/api/auth/store.go b/backend/server/api/auth/store.go
new file mode 100644
index 000000000..751624e82
--- /dev/null
+++ b/backend/server/api/auth/store.go
@@ -0,0 +1,76 @@
+/*
+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 auth
+
+import (
+ "time"
+
+ "github.com/apache/incubator-devlake/core/dal"
+ "github.com/apache/incubator-devlake/core/errors"
+)
+
+// AuthSession is the persisted record backing one signed session JWT. It is
+// what makes server-side revocation possible: the JWT carries the jti, and a
+// row here can be flagged revoked to block reuse before the token's natural
+// expiry.
+type AuthSession struct {
+ Jti string `gorm:"primaryKey;type:varchar(36)" json:"jti"`
+ Sub string `gorm:"type:varchar(255);index" json:"sub"`
+ Email string `gorm:"type:varchar(255)" json:"email"`
+ Name string `gorm:"type:varchar(255)" json:"name"`
+ IssuedAt time.Time `json:"issuedAt"`
+ ExpiresAt time.Time `gorm:"index" json:"expiresAt"`
+ RevokedAt *time.Time `gorm:"index" json:"revokedAt,omitempty"`
+ LastSeenAt time.Time `json:"lastSeenAt"`
+}
+
+func (AuthSession) TableName() string { return "auth_sessions" }
+
+func CreateSession(db dal.Dal, s *AuthSession) errors.Error {
+ return db.Create(s)
+}
+
+// RevokeSession marks the session row revoked. No-op if no row matches.
+func RevokeSession(db dal.Dal, jti string) errors.Error {
+ now := time.Now()
+ return db.UpdateColumn(&AuthSession{}, "revoked_at", now, dal.Where("jti = ?", jti))
+}
+
+// UpdateLastSeen records that the session was used. Throttled by the caller
+// (see Service.bumpLastSeen) so this isn't a per-request DB write.
+func UpdateLastSeen(db dal.Dal, jti string, at time.Time) errors.Error {
+ return db.UpdateColumn(&AuthSession{}, "last_seen_at", at, dal.Where("jti = ?", jti))
+}
+
+// ListRevoked returns the jti of every still-relevant revoked session,
+// excluding ones already past their natural expiry.
+func ListRevoked(db dal.Dal) ([]string, errors.Error) {
+ var rows []AuthSession
+ err := db.All(&rows,
+ dal.Select("jti"),
+ dal.Where("revoked_at IS NOT NULL AND expires_at > ?", time.Now()),
+ )
+ if err != nil {
+ return nil, err
+ }
+ out := make([]string, len(rows))
+ for i, r := range rows {
+ out[i] = r.Jti
+ }
+ return out, nil
+}
diff --git a/backend/server/api/router.go b/backend/server/api/router.go
index 721517cf5..a26e6b4a4 100644
--- a/backend/server/api/router.go
+++ b/backend/server/api/router.go
@@ -26,6 +26,7 @@ import (
"github.com/apache/incubator-devlake/core/errors"
"github.com/apache/incubator-devlake/impls/logruslog"
"github.com/apache/incubator-devlake/server/api/apikeys"
+ "github.com/apache/incubator-devlake/server/api/auth"
"github.com/apache/incubator-devlake/server/api/store"
"github.com/apache/incubator-devlake/core/plugin"
@@ -86,6 +87,13 @@ func RegisterRouter(r *gin.Engine, basicRes context.BasicRes) {
r.PUT("/api-keys/:apiKeyId", apikeys.PutApiKey)
r.DELETE("/api-keys/:apiKeyId", apikeys.DeleteApiKey)
+ // auth (OIDC user login)
+ r.GET(auth.PathMethods, auth.GetMethods)
+ r.GET(auth.PathLogin, auth.LoginInit)
+ r.GET(auth.PathCallback, auth.Callback)
+ r.POST(auth.PathLogout, auth.Logout)
+ r.GET(auth.PathUserInfo, auth.UserInfo)
+
// mount all api resources for all plugins
resources, err := services.GetPluginsApiResources()
if err != nil {
diff --git a/config-ui/src/routes/index.ts b/config-ui/src/api/auth/index.ts
similarity index 58%
copy from config-ui/src/routes/index.ts
copy to config-ui/src/api/auth/index.ts
index 025a0792f..96b3b046a 100644
--- a/config-ui/src/routes/index.ts
+++ b/config-ui/src/api/auth/index.ts
@@ -16,13 +16,29 @@
*
*/
-export * from './api-keys';
-export * from './blueprint';
-export * from './connection';
-export * from './db-migrate';
-export * from './error';
-export * from './layout';
-export * from './not-found';
-export * from './onboard';
-export * from './pipeline';
-export * from './project';
+import { request } from '@/utils';
+
+export type Provider = {
+ name: string;
+ displayName: string;
+ loginUrl: string;
+};
+
+export type Methods = {
+ providers?: Provider[];
+ apiKey?: {
+ enabled: boolean;
+ };
+};
+
+export type UserInfo = {
+ authenticated: boolean;
+ name: string;
+ email: string;
+};
+
+export const methods = (): Promise<Methods> => request('/auth/methods');
+
+export const userinfo = (): Promise<UserInfo> => request('/auth/userinfo');
+
+export const logout = (): Promise<{ ok: boolean; logoutUrl?: string }> => request('/auth/logout', { method: 'POST' });
diff --git a/config-ui/src/api/index.ts b/config-ui/src/api/index.ts
index f211e5640..378ba1477 100644
--- a/config-ui/src/api/index.ts
+++ b/config-ui/src/api/index.ts
@@ -19,6 +19,7 @@
import { request } from '@/utils';
import * as apiKey from './api-key';
+import * as auth from './auth';
import * as blueprint from './blueprint';
import * as connection from './connection';
import * as pipeline from './pipeline';
@@ -35,6 +36,7 @@ const version = (signal?: AbortSignal): Promise<{ version: string }> => request(
export const API = {
apiKey,
+ auth,
blueprint,
connection,
pipeline,
diff --git a/config-ui/src/app/routrer.tsx b/config-ui/src/app/router.tsx
similarity index 97%
rename from config-ui/src/app/routrer.tsx
rename to config-ui/src/app/router.tsx
index ac3113db7..c9ffe5f23 100644
--- a/config-ui/src/app/routrer.tsx
+++ b/config-ui/src/app/router.tsx
@@ -24,6 +24,7 @@ import {
Error,
Layout,
layoutLoader,
+ Login,
Connections,
Connection,
ProjectHomePage,
@@ -48,6 +49,10 @@ export const router = createBrowserRouter([
path: `${PATH_PREFIX}/db-migrate`,
element: <DBMigrate />,
},
+ {
+ path: `${PATH_PREFIX}/login`,
+ element: <Login />,
+ },
{
path: `${PATH_PREFIX}/onboard`,
element: <Onboard />,
diff --git a/config-ui/src/main.tsx b/config-ui/src/main.tsx
index d3b35c690..9f33576fa 100644
--- a/config-ui/src/main.tsx
+++ b/config-ui/src/main.tsx
@@ -24,7 +24,7 @@ import { ConfigProvider } from 'antd';
import { PageLoading } from '@/components';
import { store } from './app/store';
-import { router } from './app/routrer';
+import { router } from './app/router';
import './index.css';
ReactDOM.render(
diff --git a/config-ui/src/plugins/register/github/transformation.tsx b/config-ui/src/plugins/register/github/transformation.tsx
index 1758f7e68..0581e9315 100644
--- a/config-ui/src/plugins/register/github/transformation.tsx
+++ b/config-ui/src/plugins/register/github/transformation.tsx
@@ -453,9 +453,7 @@ const renderCollapseItems = ({
value={(transformation.prSizeExcludedFileExtensions || []).join(',')}
onChange={(e) => {
// Don't filter during onChange to allow typing commas freely
- const extensions = e.target.value
- .split(',')
- .map((s: string) => s.trim());
+ const extensions = e.target.value.split(',').map((s: string) => s.trim());
onChangeTransformation({
...transformation,
prSizeExcludedFileExtensions: extensions,
diff --git a/config-ui/src/routes/index.ts b/config-ui/src/routes/index.ts
index 025a0792f..12e9f77ba 100644
--- a/config-ui/src/routes/index.ts
+++ b/config-ui/src/routes/index.ts
@@ -22,6 +22,7 @@ export * from './connection';
export * from './db-migrate';
export * from './error';
export * from './layout';
+export * from './login';
export * from './not-found';
export * from './onboard';
export * from './pipeline';
diff --git a/config-ui/src/routes/layout/layout.tsx b/config-ui/src/routes/layout/layout.tsx
index adcffc988..5d7a02e66 100644
--- a/config-ui/src/routes/layout/layout.tsx
+++ b/config-ui/src/routes/layout/layout.tsx
@@ -19,8 +19,10 @@
import { useState, useEffect, useMemo } from 'react';
import { useLoaderData, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Helmet } from 'react-helmet';
-import { Layout as AntdLayout, Menu, Divider } from 'antd';
+import { Layout as AntdLayout, Menu, Divider, Dropdown, Button } from 'antd';
+import { UserOutlined, LogoutOutlined } from '@ant-design/icons';
+import API from '@/api';
import { PageLoading, Logo, ExternalLink } from '@/components';
import { init, selectError, selectStatus } from '@/features';
import { OnboardCard } from '@/routes/onboard/components';
@@ -36,7 +38,24 @@ export const Layout = () => {
const [openKeys, setOpenKeys] = useState<string[]>([]);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
- const { version, plugins } = useLoaderData() as { version: string; plugins: string[] };
+ const { version, plugins, user } = useLoaderData() as {
+ version: string;
+ plugins: string[];
+ user: { authenticated: boolean; name: string; email: string } | null;
+ };
+
+ const handleLogout = async () => {
+ try {
+ const res = await API.auth.logout();
+ if (res.logoutUrl) {
+ window.location.href = res.logoutUrl;
+ return;
+ }
+ } catch (e) {
+ // fall through to /login regardless
+ }
+ window.location.href = '/login';
+ };
const navigate = useNavigate();
const { pathname } = useLocation();
@@ -134,6 +153,28 @@ export const Layout = () => {
{i !== arr.length - 1 && <Divider type="vertical" />}
</ExternalLink>
))}
+ {user?.authenticated && (
+ <>
+ <Divider type="vertical" />
+ <Dropdown
+ menu={{
+ items: [
+ {
+ key: 'logout',
+ icon: <LogoutOutlined />,
+ label: 'Sign out',
+ onClick: handleLogout,
+ },
+ ],
+ }}
+ placement="bottomRight"
+ >
+ <Button type="text" icon={<UserOutlined />}>
+ {user.name || user.email || 'Account'}
+ </Button>
+ </Dropdown>
+ </>
+ )}
</Header>
<Content style={{ overflowY: 'auto' }}>
<div style={{ padding: 24, margin: '0 auto', maxWidth: 1280 }}>
diff --git a/config-ui/src/routes/layout/loader.ts b/config-ui/src/routes/layout/loader.ts
index b52021b41..2aa81b233 100644
--- a/config-ui/src/routes/layout/loader.ts
+++ b/config-ui/src/routes/layout/loader.ts
@@ -34,14 +34,16 @@ export const layoutLoader = async ({ request }: Props) => {
}
let fePlugins = getRegisterPlugins();
- const bePlugins = await API.plugin.list();
-
try {
const envPlugins = import.meta.env.DEVLAKE_PLUGINS.split(',').filter(Boolean);
fePlugins = fePlugins.filter((plugin) => !envPlugins.length || envPlugins.includes(plugin));
} catch (err) {}
- const res = await API.version(request.signal);
+ const [bePlugins, res, user] = await Promise.all([
+ API.plugin.list(),
+ API.version(request.signal),
+ API.auth.userinfo().catch(() => null),
+ ]);
return {
version: res.version,
@@ -49,5 +51,6 @@ export const layoutLoader = async ({ request }: Props) => {
fePlugins,
bePlugins.map((it) => it.plugin),
),
+ user,
};
};
diff --git a/config-ui/src/routes/login/index.tsx b/config-ui/src/routes/login/index.tsx
new file mode 100644
index 000000000..cfa9aa2ae
--- /dev/null
+++ b/config-ui/src/routes/login/index.tsx
@@ -0,0 +1,84 @@
+/*
+ * 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.
+ *
+ */
+
+import { useEffect, useState } from 'react';
+import { Card, Button, Typography, Alert, Space } from 'antd';
+
+import API from '@/api';
+import type { Methods, Provider } from '@/api/auth';
+import { TipLayout } from '@/components';
+import { DEVLAKE_ENDPOINT } from '@/config';
+
+const { Title, Paragraph } = Typography;
+
+export const Login = () => {
+ const [methods, setMethods] = useState<Methods | null>(null);
+ const [error, setError] = useState<string | null>(null);
+
+ const params = new URLSearchParams(window.location.search);
+ const returnUrl = params.get('return_url') || '/';
+
+ useEffect(() => {
+ API.auth
+ .methods()
+ .then(setMethods)
+ .catch((e) => setError(e?.message ?? 'Failed to load login methods'));
+ }, []);
+
+ const startOIDC = (p: Provider) => {
+ const sep = p.loginUrl.includes('?') ? '&' : '?';
+ window.location.href = `${DEVLAKE_ENDPOINT}${p.loginUrl}${sep}return_url=${encodeURIComponent(returnUrl)}`;
+ };
+
+ const providers = methods?.providers ?? [];
+ const apiKey = methods?.apiKey;
+ const noProviders = providers.length === 0 && !apiKey?.enabled;
+
+ return (
+ <TipLayout>
+ <Card style={{ maxWidth: 480, margin: '0 auto' }}>
+ <Title level={3} style={{ textAlign: 'center' }}>
+ Sign in to DevLake
+ </Title>
+ {error && <Alert type="error" message={error} style={{ marginBottom: 16 }} />}
+ {providers.length > 0 && (
+ <Space direction="vertical" size="middle" style={{ display: 'flex' }}>
+ {providers.map((p) => (
+ <Button key={p.name} type="primary" size="large" block onClick={() => startOIDC(p)}>
+ Sign in with {p.displayName}
+ </Button>
+ ))}
+ </Space>
+ )}
+ {providers.length === 0 && apiKey?.enabled && (
+ <Paragraph type="secondary">
+ Single Sign-On is not configured. Use an API key (Authorization: Bearer ...) to access /rest endpoints, or
+ ask your administrator to enable OIDC.
+ </Paragraph>
+ )}
+ {noProviders && methods !== null && (
+ <Alert
+ type="warning"
+ message="No login providers are enabled."
+ description="Set AUTH_ENABLED=true and configure OIDC_PROVIDERS + per-provider env vars, or sign in upstream of DevLake."
+ />
+ )}
+ </Card>
+ </TipLayout>
+ );
+};
diff --git a/config-ui/src/utils/request.ts b/config-ui/src/utils/request.ts
index a3d2e5275..e91739f72 100644
--- a/config-ui/src/utils/request.ts
+++ b/config-ui/src/utils/request.ts
@@ -23,6 +23,11 @@ import { DEVLAKE_ENDPOINT } from '@/config';
const instance = axios.create({
baseURL: DEVLAKE_ENDPOINT,
+ withCredentials: true,
+ // Double-submit CSRF: axios reads `devlake_csrf` and echoes it as
+ // `X-CSRF-Token` on unsafe methods. The cookie is set by /auth/callback.
+ xsrfCookieName: 'devlake_csrf',
+ xsrfHeaderName: 'X-CSRF-Token',
});
export type RequestConfig = {
@@ -34,6 +39,10 @@ export type RequestConfig = {
headers?: Record<string, string>;
};
+const isLoginRoute = () => window.location.pathname.replace(/\/+$/, '').endsWith('/login');
+
+let redirectingToLogin = false;
+
instance.interceptors.response.use(
(response) => response,
(error) => {
@@ -43,6 +52,12 @@ instance.interceptors.response.use(
window.location.replace('/db-migrate');
}
+ if (status === 401 && !isLoginRoute() && !redirectingToLogin) {
+ redirectingToLogin = true;
+ const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
+ window.location.replace(`/login?return_url=${returnUrl}`);
+ }
+
return Promise.reject(error);
},
);
diff --git a/config-ui/vite.config.ts b/config-ui/vite.config.ts
index d71340606..5bf4760af 100644
--- a/config-ui/vite.config.ts
+++ b/config-ui/vite.config.ts
@@ -21,7 +21,7 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import svgr from 'vite-plugin-svgr';
-// Allow Grafana access from the dev server when using Dev Container
+// Allow Grafana access from the dev server when using Dev Container
const grafanaOrigin = process.env.VITE_GRAFANA_URL || 'http://localhost:3002';
const grafanaChangeOrigin = envBool('VITE_GRAFANA_CHANGE_ORIGIN', true);
@@ -42,7 +42,7 @@ export default defineConfig({
'/grafana': {
target: grafanaOrigin,
changeOrigin: grafanaChangeOrigin,
- ws: true // Proxying websockets to allow features like query auto-complete
+ ws: true, // Proxying websockets to allow features like query auto-complete
},
},
},
diff --git a/env.example b/env.example
index 19acb7c94..1cfd04a6b 100755
--- a/env.example
+++ b/env.example
@@ -88,4 +88,68 @@ SKIP_COMMIT_FILES=true
WRAP_RESPONSE_ERROR=
# Enable subtasks by default: plugin_name:subtask_name:enabled
-ENABLE_SUBTASKS_BY_DEFAULT="jira:collectIssueChangelogs:true,jira:extractIssueChangelogs:true,jira:convertIssueChangelogs:true,tapd:collectBugChangelogs:true,tapd:extractBugChangelogs:true,tapd:convertBugChangelogs:true,zentao:collectBugRepoCommits:true,zentao:extractBugRepoCommits:true,zentao:convertBugRepoCommits:true,zentao:collectStoryRepoCommits:true,zentao:extractStoryRepoCommits:true,zentao:convertStoryRepoCommits:true,zentao:collectTaskRepoCommits:true,zentao:extractTaskRepoCommi [...]
\ No newline at end of file
+ENABLE_SUBTASKS_BY_DEFAULT="jira:collectIssueChangelogs:true,jira:extractIssueChangelogs:true,jira:convertIssueChangelogs:true,tapd:collectBugChangelogs:true,tapd:extractBugChangelogs:true,tapd:convertBugChangelogs:true,zentao:collectBugRepoCommits:true,zentao:extractBugRepoCommits:true,zentao:convertBugRepoCommits:true,zentao:collectStoryRepoCommits:true,zentao:extractStoryRepoCommits:true,zentao:convertStoryRepoCommits:true,zentao:collectTaskRepoCommits:true,zentao:extractTaskRepoCommi [...]
+
+##########################
+# OIDC / Authentication
+##########################
+# Master switch. When false (default) DevLake behaves as before: API keys for
+# /rest/* and trust X-Forwarded-User from an upstream proxy. Set true to
+# require authentication on all non-whitelisted routes.
+AUTH_ENABLED=false
+
+# OIDC user login. Requires AUTH_ENABLED=true.
+OIDC_ENABLED=false
+
+# Comma-separated provider identifiers. Each name <NAME> binds to the env
+# vars OIDC_<NAME>_ISSUER_URL, OIDC_<NAME>_CLIENT_ID, etc. Add a name and a
+# matching block of vars to onboard another IdP.
+# Example: OIDC_PROVIDERS=entra,google
+OIDC_PROVIDERS=
+
+# Per-provider config. Replicate the OIDC_ENTRA_* block under a different
+# prefix for each name listed in OIDC_PROVIDERS.
+# Microsoft Entra ID example: https://login.microsoftonline.com/<TENANT_ID>/v2.0
+OIDC_ENTRA_ISSUER_URL=
+OIDC_ENTRA_CLIENT_ID=
+OIDC_ENTRA_CLIENT_SECRET=
+# Must match the redirect URI registered with the IdP. The path is the same
+# for every provider; the state cookie disambiguates which one comes back.
+# Devcontainer dev: http://localhost:4000/api/auth/callback
+OIDC_ENTRA_REDIRECT_URL=
+# Comma-separated. `openid` is required.
+OIDC_ENTRA_SCOPES=openid,profile,email
+# Label rendered on the UI login button.
+OIDC_ENTRA_DISPLAY_NAME=Entra ID
+# Authenticate the code exchange with an Azure Workload Identity federated
+# assertion (read from the SA token file injected by the workload-identity
+# webhook) instead of OIDC_ENTRA_CLIENT_SECRET. Requires the pod label
+# `azure.workload.identity/use: "true"` and a federated credential on the
+# Entra App Registration. Entra-only.
+OIDC_ENTRA_USE_WORKLOAD_IDENTITY=false
+
+# Google example — create an OAuth 2.0 Web client at console.cloud.google.com
+# (APIs & Services → Credentials). Configure the OAuth consent screen first
+# and add yourself as a test user while the app is in Testing status.
+OIDC_GOOGLE_ISSUER_URL=https://accounts.google.com
+OIDC_GOOGLE_CLIENT_ID=
+OIDC_GOOGLE_CLIENT_SECRET=
+OIDC_GOOGLE_REDIRECT_URL=
+OIDC_GOOGLE_SCOPES=openid,profile,email
+OIDC_GOOGLE_DISPLAY_NAME=Google
+
+# When true, /auth/logout returns the IdP's end_session_endpoint so the UI
+# can also sign the user out at the IdP.
+OIDC_LOGOUT_REDIRECT=false
+
+# Required when AUTH_ENABLED=true. At least 32 bytes of high-entropy data.
+# Used to sign session JWTs (HS256) and to derive the AES-GCM key that
+# encrypts the OIDC state cookie. Rotating this invalidates all sessions.
+SESSION_SECRET=
+# How long a session cookie is valid. Format: any time.ParseDuration value.
+SESSION_TTL=8h
+# Leave empty for host-only cookies. Set when serving the API and UI from
+# different subdomains of the same parent (e.g. .example.com).
+COOKIE_DOMAIN=
+# Set to false ONLY for local HTTP development.
+COOKIE_SECURE=true