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