feat: 接入 OIDC 公共客户端登录链路
由 Gateway 服务端完成 PKCE 换码、ID Token 校验、JIT 用户解析和 Session 建立。新增登录、回调、退出与本地会话删除接口,并移除旧的 Access Token Cookie 桥接接口。
This commit is contained in:
@@ -1,140 +1,90 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession"
|
||||
)
|
||||
|
||||
func TestCreateOIDCBrowserSessionSetsProtectedSharedCookie(t *testing.T) {
|
||||
server, token, closeIssuer := newOIDCSessionTestServer(t)
|
||||
defer closeIssuer()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/session", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testing.T) {
|
||||
cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
|
||||
client := &fakeOIDCClient{authorizationURL: "https://auth.example.com/authorize?request=redacted"}
|
||||
server := &Server{
|
||||
cfg: config.Config{OIDCEnabled: true, OIDCBrowserSessionEnabled: true, OIDCSessionCookieSecure: true},
|
||||
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: client,
|
||||
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher,
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?returnTo=%2Fworkspace%3Ftab%3Dwallet", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.createOIDCBrowserSession(recorder, request)
|
||||
|
||||
server.startOIDCLogin(recorder, request)
|
||||
response := recorder.Result()
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("session creation status = %d, want 204", response.StatusCode)
|
||||
if response.StatusCode != http.StatusSeeOther || response.Header.Get("Location") != client.authorizationURL {
|
||||
t.Fatalf("login status=%d location=%q", response.StatusCode, response.Header.Get("Location"))
|
||||
}
|
||||
var sessionCookie *http.Cookie
|
||||
for _, cookie := range response.Cookies() {
|
||||
if cookie.Name == auth.OIDCSessionCookieName {
|
||||
sessionCookie = cookie
|
||||
break
|
||||
}
|
||||
cookies := response.Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != oidcsession.LoginTransactionCookieName || !cookies[0].HttpOnly || !cookies[0].Secure || cookies[0].SameSite != http.SameSiteLaxMode || cookies[0].MaxAge != 600 {
|
||||
t.Fatalf("unsafe login transaction cookie: %#v", cookies)
|
||||
}
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("OIDC session cookie was not set")
|
||||
transaction, err := cipher.DecodeLoginTransaction(cookies[0].Value, cookies[0].Expires.Add(-time.Minute))
|
||||
if err != nil || transaction.ReturnTo != "/workspace?tab=wallet" {
|
||||
t.Fatalf("transaction=%#v err=%v", transaction, err)
|
||||
}
|
||||
if !sessionCookie.HttpOnly || sessionCookie.SameSite != http.SameSiteStrictMode || sessionCookie.Path != "/" {
|
||||
t.Fatalf("unsafe OIDC session cookie attributes: %#v", sessionCookie)
|
||||
}
|
||||
if sessionCookie.MaxAge <= 0 || sessionCookie.Expires.IsZero() {
|
||||
t.Fatalf("OIDC session cookie did not inherit token expiration: %#v", sessionCookie)
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(body), token) {
|
||||
t.Fatal("OIDC access token leaked into session response body")
|
||||
if client.state == "" || client.nonce == "" || client.challenge == "" {
|
||||
t.Fatal("authorization redirect omitted state, nonce or PKCE challenge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOIDCBrowserSessionRejectsNonOIDCCredential(t *testing.T) {
|
||||
server, _, closeIssuer := newOIDCSessionTestServer(t)
|
||||
defer closeIssuer()
|
||||
localToken, err := server.auth.SignJWT(&auth.User{ID: "local-user", Source: "gateway", Roles: []string{"user"}}, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
func TestStartOIDCLoginRejectsOpenRedirect(t *testing.T) {
|
||||
cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
|
||||
server := &Server{
|
||||
cfg: config.Config{OIDCEnabled: true, OIDCBrowserSessionEnabled: true},
|
||||
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: &fakeOIDCClient{},
|
||||
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/session", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+localToken)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.createOIDCBrowserSession(recorder, request)
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("local credential session creation status = %d, want 401", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOIDCBrowserSessionRejectsOversizedTokenBeforeCookieWrite(t *testing.T) {
|
||||
server, _, closeIssuer := newOIDCSessionTestServer(t)
|
||||
defer closeIssuer()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/session", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+strings.Repeat("a", maxOIDCSessionCookieTokenBytes+1))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.createOIDCBrowserSession(recorder, request)
|
||||
|
||||
server.startOIDCLogin(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?returnTo=https%3A%2F%2Fevil.example", nil))
|
||||
if recorder.Code != http.StatusBadRequest || recorder.Header().Get("Set-Cookie") != "" {
|
||||
t.Fatalf("oversized token response status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie"))
|
||||
t.Fatalf("open redirect status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOIDCBrowserSessionHonorsDisabledFlag(t *testing.T) {
|
||||
server, token, closeIssuer := newOIDCSessionTestServer(t)
|
||||
defer closeIssuer()
|
||||
server.cfg.OIDCBrowserSessionEnabled = false
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/session", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.createOIDCBrowserSession(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusNotFound || recorder.Header().Get("Set-Cookie") != "" {
|
||||
t.Fatalf("disabled session response status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteOIDCBrowserSessionExpiresCookie(t *testing.T) {
|
||||
server, _, closeIssuer := newOIDCSessionTestServer(t)
|
||||
defer closeIssuer()
|
||||
func TestDeleteOIDCBrowserSessionIsIdempotentAndExpiresCookie(t *testing.T) {
|
||||
sessions := &fakeOIDCSessions{}
|
||||
server := &Server{cfg: config.Config{OIDCSessionCookieSecure: true}, oidcSessions: sessions}
|
||||
request := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/oidc/session", nil)
|
||||
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "session-token"})
|
||||
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "opaque-session"})
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.deleteOIDCBrowserSession(recorder, request)
|
||||
response := recorder.Result()
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("session deletion status = %d, want 204", response.StatusCode)
|
||||
if response.StatusCode != http.StatusNoContent || sessions.deleted != "opaque-session" {
|
||||
t.Fatalf("delete status=%d session=%q", response.StatusCode, sessions.deleted)
|
||||
}
|
||||
cookies := response.Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != auth.OIDCSessionCookieName || cookies[0].MaxAge >= 0 {
|
||||
t.Fatalf("OIDC session cookie was not expired: %#v", cookies)
|
||||
if len(cookies) != 1 || cookies[0].Name != auth.OIDCSessionCookieName || cookies[0].MaxAge >= 0 || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteStrictMode {
|
||||
t.Fatalf("OIDC session cookie was not safely expired: %#v", cookies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) {
|
||||
server := &Server{cfg: config.Config{
|
||||
OIDCEnabled: true,
|
||||
OIDCBrowserSessionEnabled: true,
|
||||
CORSAllowedOrigin: "https://gateway.example.com",
|
||||
}}
|
||||
server := &Server{cfg: config.Config{OIDCEnabled: true, OIDCBrowserSessionEnabled: true, CORSAllowedOrigin: "https://gateway.example.com"}}
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
handler := server.protectOIDCSessionCookie(next)
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
method string
|
||||
origin string
|
||||
bearer bool
|
||||
wantStatus int
|
||||
name, method, origin string
|
||||
bearer bool
|
||||
wantStatus int
|
||||
}{
|
||||
{name: "missing origin", method: http.MethodPost, wantStatus: http.StatusForbidden},
|
||||
{name: "foreign origin", method: http.MethodDelete, origin: "https://evil.example", wantStatus: http.StatusForbidden},
|
||||
@@ -145,9 +95,7 @@ func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
request := httptest.NewRequest(test.method, "/api/workspace/tasks", nil)
|
||||
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "session-token"})
|
||||
if test.origin != "" {
|
||||
request.Header.Set("Origin", test.origin)
|
||||
}
|
||||
request.Header.Set("Origin", test.origin)
|
||||
if test.bearer {
|
||||
request.Header.Set("Authorization", "Bearer explicit-token")
|
||||
}
|
||||
@@ -161,60 +109,44 @@ func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) {
|
||||
server := &Server{cfg: config.Config{
|
||||
OIDCEnabled: false,
|
||||
OIDCBrowserSessionEnabled: true,
|
||||
}}
|
||||
server := &Server{cfg: config.Config{OIDCEnabled: false, OIDCBrowserSessionEnabled: true}}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
|
||||
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "irrelevant-cookie"})
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.protectOIDCSessionCookie(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})).ServeHTTP(recorder, request)
|
||||
|
||||
server.protectOIDCSessionCookie(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })).ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("OIDC-disabled request status = %d, want 204", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func newOIDCSessionTestServer(t *testing.T) (*Server, string, func()) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var issuer string
|
||||
issuerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/.well-known/openid-configuration":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"issuer": issuer, "jwks_uri": issuer + "/jwks"})
|
||||
case "/jwks":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{oidcJITECJWK("jit-key", &key.PublicKey)}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
issuer = issuerServer.URL
|
||||
verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{
|
||||
Issuer: issuer, Audience: "gateway-api", TenantID: "auth-center-test-tenant",
|
||||
RolePrefix: "gateway.", RequiredScopes: []string{"gateway.access"}, HTTPClient: issuerServer.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
issuerServer.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
authenticator := auth.New("test-local-jwt-secret", "", "")
|
||||
authenticator.OIDCVerifier = verifier
|
||||
server := &Server{
|
||||
cfg: config.Config{
|
||||
OIDCEnabled: true,
|
||||
OIDCBrowserSessionEnabled: true,
|
||||
OIDCSessionCookieSecure: false,
|
||||
CORSAllowedOrigin: "http://localhost:5178",
|
||||
},
|
||||
auth: authenticator,
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
return server, signedOIDCJITToken(t, key, issuer, "session-user", nil), issuerServer.Close
|
||||
type fakeOIDCClient struct {
|
||||
authorizationURL string
|
||||
state, nonce, challenge string
|
||||
}
|
||||
|
||||
func (f *fakeOIDCClient) AuthorizationURL(_ context.Context, state, nonce, challenge string) (string, error) {
|
||||
f.state, f.nonce, f.challenge = state, nonce, challenge
|
||||
return f.authorizationURL, nil
|
||||
}
|
||||
func (f *fakeOIDCClient) ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error) {
|
||||
return auth.OIDCTokenResponse{}, nil
|
||||
}
|
||||
func (f *fakeOIDCClient) Refresh(context.Context, string) (auth.OIDCTokenResponse, error) {
|
||||
return auth.OIDCTokenResponse{}, nil
|
||||
}
|
||||
func (f *fakeOIDCClient) RevokeRefreshToken(context.Context, string) error { return nil }
|
||||
func (f *fakeOIDCClient) EndSessionURL(context.Context, string) (string, error) {
|
||||
return "https://gateway.example.com/", nil
|
||||
}
|
||||
|
||||
type fakeOIDCSessions struct{ deleted string }
|
||||
|
||||
func (f *fakeOIDCSessions) Create(context.Context, oidcsession.TokenBundle, *auth.User) (string, error) {
|
||||
return "opaque-session", nil
|
||||
}
|
||||
func (f *fakeOIDCSessions) Resolve(context.Context, string) (*auth.User, error) { return nil, nil }
|
||||
func (f *fakeOIDCSessions) Delete(_ context.Context, raw string) (oidcsession.TokenBundle, error) {
|
||||
f.deleted = raw
|
||||
return oidcsession.TokenBundle{}, nil
|
||||
}
|
||||
func (f *fakeOIDCSessions) Cleanup(context.Context) (int64, error) { return 0, nil }
|
||||
|
||||
Reference in New Issue
Block a user