| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310 |
- package handler
- import (
- "bytes"
- "context"
- "crypto/rand"
- "crypto/rsa"
- "encoding/base64"
- "encoding/json"
- "math/big"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
- dbent "github.com/Wei-Shaw/sub2api/ent"
- "github.com/Wei-Shaw/sub2api/ent/authidentity"
- "github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
- "github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
- dbuser "github.com/Wei-Shaw/sub2api/ent/user"
- "github.com/Wei-Shaw/sub2api/internal/config"
- servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- "github.com/golang-jwt/jwt/v5"
- "github.com/stretchr/testify/require"
- )
- func TestOIDCSyntheticEmailStableAndDistinct(t *testing.T) {
- k1 := oidcIdentityKey("https://issuer.example.com", "subject-a")
- k2 := oidcIdentityKey("https://issuer.example.com", "subject-b")
- e1 := oidcSyntheticEmailFromIdentityKey(k1)
- e1Again := oidcSyntheticEmailFromIdentityKey(k1)
- e2 := oidcSyntheticEmailFromIdentityKey(k2)
- require.Equal(t, e1, e1Again)
- require.NotEqual(t, e1, e2)
- require.Contains(t, e1, "@oidc-connect.invalid")
- }
- func TestBuildOIDCAuthorizeURLIncludesNonceAndPKCE(t *testing.T) {
- cfg := config.OIDCConnectConfig{
- AuthorizeURL: "https://issuer.example.com/auth",
- ClientID: "cid",
- Scopes: "openid email profile",
- }
- u, err := buildOIDCAuthorizeURL(cfg, "state123", "nonce123", "challenge123", "https://app.example.com/callback")
- require.NoError(t, err)
- require.Contains(t, u, "nonce=nonce123")
- require.Contains(t, u, "code_challenge=challenge123")
- require.Contains(t, u, "code_challenge_method=S256")
- require.Contains(t, u, "scope=openid+email+profile")
- }
- func TestOIDCParseAndValidateIDToken(t *testing.T) {
- priv, err := rsa.GenerateKey(rand.Reader, 2048)
- require.NoError(t, err)
- kid := "kid-1"
- jwks := oidcJWKSet{Keys: []oidcJWK{buildRSAJWK(kid, &priv.PublicKey)}}
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- require.NoError(t, json.NewEncoder(w).Encode(jwks))
- }))
- defer srv.Close()
- now := time.Now()
- claims := oidcIDTokenClaims{
- Nonce: "nonce-ok",
- Azp: "client-1",
- RegisteredClaims: jwt.RegisteredClaims{
- Issuer: "https://issuer.example.com",
- Subject: "subject-1",
- Audience: jwt.ClaimStrings{"client-1", "another-aud"},
- IssuedAt: jwt.NewNumericDate(now),
- NotBefore: jwt.NewNumericDate(now.Add(-30 * time.Second)),
- ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
- },
- }
- tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
- tok.Header["kid"] = kid
- signed, err := tok.SignedString(priv)
- require.NoError(t, err)
- cfg := config.OIDCConnectConfig{
- ClientID: "client-1",
- IssuerURL: "https://issuer.example.com",
- JWKSURL: srv.URL,
- AllowedSigningAlgs: "RS256",
- ClockSkewSeconds: 120,
- }
- parsed, err := oidcParseAndValidateIDToken(context.Background(), cfg, signed, "nonce-ok")
- require.NoError(t, err)
- require.Equal(t, "subject-1", parsed.Subject)
- require.Equal(t, "https://issuer.example.com", parsed.Issuer)
- _, err = oidcParseAndValidateIDToken(context.Background(), cfg, signed, "bad-nonce")
- require.Error(t, err)
- }
- func TestOIDCParseUserInfoIncludesSuggestedProfile(t *testing.T) {
- cfg := config.OIDCConnectConfig{}
- claims := oidcParseUserInfo(`{
- "sub":"subject-1",
- "preferred_username":"alice",
- "name":"Alice Example",
- "picture":"https://cdn.example/avatar.png",
- "email":"alice@example.com",
- "email_verified":true
- }`, cfg)
- require.Equal(t, "subject-1", claims.Subject)
- require.Equal(t, "alice", claims.Username)
- require.Equal(t, "Alice Example", claims.DisplayName)
- require.Equal(t, "https://cdn.example/avatar.png", claims.AvatarURL)
- require.NotNil(t, claims.EmailVerified)
- require.True(t, *claims.EmailVerified)
- }
- func buildRSAJWK(kid string, pub *rsa.PublicKey) oidcJWK {
- n := base64.RawURLEncoding.EncodeToString(pub.N.Bytes())
- e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes())
- return oidcJWK{
- Kty: "RSA",
- Kid: kid,
- Use: "sig",
- Alg: "RS256",
- N: n,
- E: e,
- }
- }
- func TestOIDCOAuthBindStartRedirectsAndSetsBindCookies(t *testing.T) {
- handler := newOIDCOAuthTestHandler(t, false, config.OIDCConnectConfig{
- Enabled: true,
- ClientID: "oidc-client",
- ClientSecret: "oidc-secret",
- IssuerURL: "https://issuer.example.com",
- AuthorizeURL: "https://issuer.example.com/oauth/authorize",
- TokenURL: "https://issuer.example.com/oauth/token",
- UserInfoURL: "https://issuer.example.com/oauth/userinfo",
- JWKSURL: "https://issuer.example.com/oauth/jwks",
- Scopes: "openid profile email",
- RedirectURL: "https://api.example.com/api/v1/auth/oauth/oidc/callback",
- FrontendRedirectURL: "/auth/oidc/callback",
- TokenAuthMethod: "client_secret_post",
- UsePKCE: true,
- ValidateIDToken: true,
- AllowedSigningAlgs: "RS256",
- ClockSkewSeconds: 120,
- RequireEmailVerified: false,
- })
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/bind/start?intent=bind_current_user&redirect=/settings/connections", nil)
- c.Request = req
- c.Set(string(servermiddleware.ContextKeyUser), servermiddleware.AuthSubject{UserID: 84})
- handler.OIDCOAuthStart(c)
- require.Equal(t, http.StatusFound, recorder.Code)
- location := recorder.Header().Get("Location")
- require.Contains(t, location, "issuer.example.com/oauth/authorize")
- require.Contains(t, location, "client_id=oidc-client")
- require.Contains(t, location, "nonce=")
- cookies := recorder.Result().Cookies()
- require.NotNil(t, findCookie(cookies, oidcOAuthStateCookieName))
- require.NotNil(t, findCookie(cookies, oidcOAuthRedirectCookie))
- require.NotNil(t, findCookie(cookies, oidcOAuthVerifierCookie))
- require.NotNil(t, findCookie(cookies, oidcOAuthNonceCookie))
- require.NotNil(t, findCookie(cookies, oauthPendingBrowserCookieName))
- intentCookie := findCookie(cookies, oidcOAuthIntentCookieName)
- require.NotNil(t, intentCookie)
- require.Equal(t, oauthIntentBindCurrentUser, decodeCookieValueForTest(t, intentCookie.Value))
- bindCookie := findCookie(cookies, oidcOAuthBindUserCookieName)
- require.NotNil(t, bindCookie)
- userID, err := parseOAuthBindUserCookieValue(decodeCookieValueForTest(t, bindCookie.Value), "test-secret")
- require.NoError(t, err)
- require.Equal(t, int64(84), userID)
- }
- func TestOIDCOAuthStartOmitsPKCEAndNonceWhenDisabled(t *testing.T) {
- handler := newOIDCOAuthTestHandler(t, false, config.OIDCConnectConfig{
- Enabled: true,
- ClientID: "oidc-client",
- ClientSecret: "oidc-secret",
- IssuerURL: "https://issuer.example.com",
- AuthorizeURL: "https://issuer.example.com/oauth/authorize",
- TokenURL: "https://issuer.example.com/oauth/token",
- UserInfoURL: "https://issuer.example.com/oauth/userinfo",
- Scopes: "openid profile email",
- RedirectURL: "https://api.example.com/api/v1/auth/oauth/oidc/callback",
- FrontendRedirectURL: "/auth/oidc/callback",
- TokenAuthMethod: "client_secret_post",
- UsePKCE: false,
- ValidateIDToken: false,
- RequireEmailVerified: false,
- })
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/start?redirect=/dashboard", nil)
- handler.OIDCOAuthStart(c)
- require.Equal(t, http.StatusFound, recorder.Code)
- location := recorder.Header().Get("Location")
- require.NotContains(t, location, "code_challenge=")
- require.NotContains(t, location, "nonce=")
- require.Nil(t, findCookie(recorder.Result().Cookies(), oidcOAuthVerifierCookie))
- require.Nil(t, findCookie(recorder.Result().Cookies(), oidcOAuthNonceCookie))
- }
- func TestOIDCOAuthCallbackAllowsOptionalPKCEAndIDTokenValidation(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/token":
- require.NoError(t, r.ParseForm())
- require.Empty(t, r.PostForm.Get("code_verifier"))
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"access_token":"oidc-access","token_type":"Bearer","expires_in":3600}`))
- case "/userinfo":
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"sub":"oidc-subject-compat","preferred_username":"oidc_user","name":"OIDC Display","email":"oidc@example.com"}`))
- default:
- http.NotFound(w, r)
- }
- }))
- defer upstream.Close()
- handler, client := newOIDCOAuthHandlerAndClient(t, false, config.OIDCConnectConfig{
- Enabled: true,
- ClientID: "oidc-client",
- ClientSecret: "oidc-secret",
- IssuerURL: "https://issuer.example.com",
- AuthorizeURL: upstream.URL + "/authorize",
- TokenURL: upstream.URL + "/token",
- UserInfoURL: upstream.URL + "/userinfo",
- Scopes: "openid profile email",
- RedirectURL: "https://api.example.com/api/v1/auth/oauth/oidc/callback",
- FrontendRedirectURL: "/auth/oidc/callback",
- TokenAuthMethod: "client_secret_post",
- UsePKCE: false,
- ValidateIDToken: false,
- RequireEmailVerified: false,
- })
- t.Cleanup(func() { _ = client.Close() })
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-123", nil)
- req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-123"))
- req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/dashboard"))
- req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
- req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-123"))
- c.Request = req
- handler.OIDCOAuthCallback(c)
- require.Equal(t, http.StatusFound, recorder.Code)
- require.Equal(t, "/auth/oidc/callback", recorder.Header().Get("Location"))
- require.NotNil(t, findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName))
- }
- func TestOIDCOAuthCallbackCreatesLoginPendingSessionForExistingIdentityUser(t *testing.T) {
- cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
- Subject: "oidc-subject-login",
- PreferredUsername: "oidc_login",
- DisplayName: "OIDC Login Display",
- AvatarURL: "https://cdn.example/oidc-login.png",
- Email: "oidc-login@example.com",
- EmailVerified: true,
- })
- defer cleanup()
- handler, client := newOIDCOAuthHandlerAndClient(t, false, cfg)
- t.Cleanup(func() { _ = client.Close() })
- ctx := context.Background()
- existingUser, err := client.User.Create().
- SetEmail(oidcSyntheticEmailFromIdentityKey(oidcIdentityKey(cfg.IssuerURL, "oidc-subject-login"))).
- SetUsername("legacy-user").
- SetPasswordHash("hash").
- SetRole(service.RoleUser).
- SetStatus(service.StatusActive).
- Save(ctx)
- require.NoError(t, err)
- _, err = client.AuthIdentity.Create().
- SetUserID(existingUser.ID).
- SetProviderType("oidc").
- SetProviderKey(cfg.IssuerURL).
- SetProviderSubject("oidc-subject-login").
- SetMetadata(map[string]any{"username": "legacy-user"}).
- Save(ctx)
- require.NoError(t, err)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-123", nil)
- req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-123"))
- req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/dashboard"))
- req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-123"))
- req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-subject-login"))
- req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
- req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-123"))
- c.Request = req
- handler.OIDCOAuthCallback(c)
- require.Equal(t, http.StatusFound, recorder.Code)
- require.Equal(t, "/auth/oidc/callback", recorder.Header().Get("Location"))
- sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
- require.NotNil(t, sessionCookie)
- session, err := client.PendingAuthSession.Query().
- Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
- Only(ctx)
- require.NoError(t, err)
- require.Equal(t, oauthIntentLogin, session.Intent)
- require.NotNil(t, session.TargetUserID)
- require.Equal(t, existingUser.ID, *session.TargetUserID)
- require.Equal(t, cfg.IssuerURL, session.ProviderKey)
- require.Equal(t, "OIDC Login Display", session.UpstreamIdentityClaims["suggested_display_name"])
- completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
- require.True(t, ok)
- require.Equal(t, "/dashboard", completion["redirect"])
- _, hasAccessToken := completion["access_token"]
- require.False(t, hasAccessToken)
- _, hasRefreshToken := completion["refresh_token"]
- require.False(t, hasRefreshToken)
- require.Nil(t, completion["error"])
- }
- func TestOIDCOAuthCallbackRejectsDisabledExistingIdentityUser(t *testing.T) {
- cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
- Subject: "oidc-disabled-subject",
- PreferredUsername: "oidc_disabled",
- DisplayName: "OIDC Disabled",
- })
- defer cleanup()
- handler, client := newOIDCOAuthHandlerAndClient(t, false, cfg)
- t.Cleanup(func() { _ = client.Close() })
- ctx := context.Background()
- existingUser, err := client.User.Create().
- SetEmail(oidcSyntheticEmailFromIdentityKey(oidcIdentityKey(cfg.IssuerURL, "oidc-disabled-subject"))).
- SetUsername("disabled-user").
- SetPasswordHash("hash").
- SetRole(service.RoleUser).
- SetStatus(service.StatusDisabled).
- Save(ctx)
- require.NoError(t, err)
- _, err = client.AuthIdentity.Create().
- SetUserID(existingUser.ID).
- SetProviderType("oidc").
- SetProviderKey(cfg.IssuerURL).
- SetProviderSubject("oidc-disabled-subject").
- Save(ctx)
- require.NoError(t, err)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-disabled", nil)
- req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-disabled"))
- req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/dashboard"))
- req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-disabled"))
- req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-disabled-subject"))
- req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
- req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-disabled"))
- c.Request = req
- handler.OIDCOAuthCallback(c)
- require.Equal(t, http.StatusFound, recorder.Code)
- require.Nil(t, findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName))
- assertOAuthRedirectError(t, recorder.Header().Get("Location"), "session_error", "USER_NOT_ACTIVE")
- count, err := client.PendingAuthSession.Query().Count(ctx)
- require.NoError(t, err)
- require.Zero(t, count)
- }
- func TestOIDCOAuthCallbackCreatesBindPendingSessionForCompatEmailUser(t *testing.T) {
- cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
- Subject: "oidc-subject-compat",
- PreferredUsername: "oidc_compat",
- DisplayName: "OIDC Compat Display",
- AvatarURL: "https://cdn.example/oidc-compat.png",
- Email: "legacy@example.com",
- EmailVerified: true,
- })
- defer cleanup()
- handler, client := newOIDCOAuthHandlerAndClient(t, false, cfg)
- t.Cleanup(func() { _ = client.Close() })
- ctx := context.Background()
- existingUser, err := client.User.Create().
- SetEmail("legacy@example.com").
- SetUsername("legacy-user").
- SetPasswordHash("hash").
- SetRole(service.RoleUser).
- SetStatus(service.StatusActive).
- Save(ctx)
- require.NoError(t, err)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-compat", nil)
- req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-compat"))
- req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/dashboard"))
- req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-compat"))
- req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-subject-compat"))
- req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
- req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-compat"))
- c.Request = req
- handler.OIDCOAuthCallback(c)
- require.Equal(t, http.StatusFound, recorder.Code)
- require.Equal(t, "/auth/oidc/callback", recorder.Header().Get("Location"))
- sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
- require.NotNil(t, sessionCookie)
- session, err := client.PendingAuthSession.Query().
- Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
- Only(ctx)
- require.NoError(t, err)
- require.Equal(t, oauthIntentLogin, session.Intent)
- require.NotNil(t, session.TargetUserID)
- require.Equal(t, existingUser.ID, *session.TargetUserID)
- require.Equal(t, existingUser.Email, session.ResolvedEmail)
- require.Equal(t, "legacy@example.com", session.UpstreamIdentityClaims["compat_email"])
- completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
- require.True(t, ok)
- require.Equal(t, "/dashboard", completion["redirect"])
- require.Equal(t, oauthPendingChoiceStep, completion["step"])
- require.Equal(t, existingUser.Email, completion["email"])
- require.Equal(t, existingUser.Email, completion["existing_account_email"])
- require.Equal(t, true, completion["existing_account_bindable"])
- require.Equal(t, "compat_email_match", completion["choice_reason"])
- _, hasAccessToken := completion["access_token"]
- require.False(t, hasAccessToken)
- }
- func TestOIDCOAuthCallbackAllowsCompatEmailBindWhenUpstreamEmailIsUnverified(t *testing.T) {
- cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
- Subject: "oidc-subject-unverified-compat",
- PreferredUsername: "oidc_unverified",
- DisplayName: "OIDC Unverified Compat Display",
- AvatarURL: "https://cdn.example/oidc-unverified.png",
- Email: "owner@example.com",
- EmailVerified: false,
- })
- defer cleanup()
- cfg.RequireEmailVerified = true
- handler, client := newOIDCOAuthHandlerAndClient(t, false, cfg)
- t.Cleanup(func() { _ = client.Close() })
- ctx := context.Background()
- _, err := client.User.Create().
- SetEmail("owner@example.com").
- SetUsername("owner-user").
- SetPasswordHash("hash").
- SetRole(service.RoleUser).
- SetStatus(service.StatusActive).
- Save(ctx)
- require.NoError(t, err)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-unverified-compat", nil)
- req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-unverified-compat"))
- req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/settings/connections"))
- req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-unverified-compat"))
- req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-subject-unverified-compat"))
- req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
- req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-unverified-compat"))
- c.Request = req
- handler.OIDCOAuthCallback(c)
- require.Equal(t, http.StatusFound, recorder.Code)
- require.Equal(t, "/auth/oidc/callback#error=email_not_verified&error_message=email+is+not+verified", recorder.Header().Get("Location"))
- require.Nil(t, findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName))
- count, err := client.PendingAuthSession.Query().Count(ctx)
- require.NoError(t, err)
- require.Zero(t, count)
- }
- func TestOIDCOAuthCallbackCreatesChoicePendingSessionWhenSignupRequiresInvite(t *testing.T) {
- cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
- Subject: "oidc-subject-invite",
- PreferredUsername: "oidc_invite",
- DisplayName: "OIDC Invite Display",
- AvatarURL: "https://cdn.example/oidc-invite.png",
- Email: "oidc-invite@example.com",
- EmailVerified: true,
- })
- defer cleanup()
- handler, client := newOIDCOAuthHandlerAndClient(t, true, cfg)
- t.Cleanup(func() { _ = client.Close() })
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-456", nil)
- req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-456"))
- req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/dashboard"))
- req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-456"))
- req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-subject-invite"))
- req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
- req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-456"))
- c.Request = req
- handler.OIDCOAuthCallback(c)
- require.Equal(t, http.StatusFound, recorder.Code)
- require.Equal(t, "/auth/oidc/callback", recorder.Header().Get("Location"))
- sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
- require.NotNil(t, sessionCookie)
- ctx := context.Background()
- session, err := client.PendingAuthSession.Query().
- Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
- Only(ctx)
- require.NoError(t, err)
- require.Equal(t, oauthIntentLogin, session.Intent)
- require.Nil(t, session.TargetUserID)
- completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
- require.True(t, ok)
- require.Equal(t, oauthPendingChoiceStep, completion["step"])
- require.Equal(t, "/dashboard", completion["redirect"])
- require.Equal(t, "third_party_signup", completion["choice_reason"])
- }
- func TestOIDCOAuthCallbackCreatesBindPendingSessionForCurrentUser(t *testing.T) {
- cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
- Subject: "oidc-subject-bind",
- PreferredUsername: "oidc_bind",
- DisplayName: "OIDC Bind Display",
- AvatarURL: "https://cdn.example/oidc-bind.png",
- Email: "oidc-bind@example.com",
- EmailVerified: true,
- })
- defer cleanup()
- handler, client := newOIDCOAuthHandlerAndClient(t, false, cfg)
- t.Cleanup(func() { _ = client.Close() })
- ctx := context.Background()
- currentUser, err := client.User.Create().
- SetEmail("current@example.com").
- SetUsername("current-user").
- SetPasswordHash("hash").
- SetRole(service.RoleUser).
- SetStatus(service.StatusActive).
- Save(ctx)
- require.NoError(t, err)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-bind", nil)
- req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-bind"))
- req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/settings/connections"))
- req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-bind"))
- req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-subject-bind"))
- req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentBindCurrentUser))
- req.AddCookie(encodedCookie(oidcOAuthBindUserCookieName, buildEncodedOAuthBindUserCookie(t, currentUser.ID, "test-secret")))
- req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-bind"))
- c.Request = req
- handler.OIDCOAuthCallback(c)
- require.Equal(t, http.StatusFound, recorder.Code)
- require.Equal(t, "/auth/oidc/callback", recorder.Header().Get("Location"))
- sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
- require.NotNil(t, sessionCookie)
- session, err := client.PendingAuthSession.Query().
- Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
- Only(ctx)
- require.NoError(t, err)
- require.Equal(t, oauthIntentBindCurrentUser, session.Intent)
- require.NotNil(t, session.TargetUserID)
- require.Equal(t, currentUser.ID, *session.TargetUserID)
- require.Equal(t, cfg.IssuerURL, session.ProviderKey)
- require.Equal(t, "OIDC Bind Display", session.UpstreamIdentityClaims["suggested_display_name"])
- completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
- require.True(t, ok)
- require.Equal(t, "/settings/connections", completion["redirect"])
- require.Empty(t, completion["access_token"])
- userCount, err := client.User.Query().Count(ctx)
- require.NoError(t, err)
- require.Equal(t, 1, userCount)
- }
- func TestCompleteOIDCOAuthRegistrationAppliesPendingAdoptionDecision(t *testing.T) {
- handler, client := newOAuthPendingFlowTestHandler(t, false)
- ctx := context.Background()
- session, err := client.PendingAuthSession.Create().
- SetSessionToken("oidc-complete-session").
- SetIntent("login").
- SetProviderType("oidc").
- SetProviderKey("https://issuer.example.com").
- SetProviderSubject("oidc-subject-1").
- SetResolvedEmail("93a310f4c1944c5bbd2e246df1f76485@oidc-connect.invalid").
- SetBrowserSessionKey("oidc-browser").
- SetUpstreamIdentityClaims(map[string]any{
- "username": "oidc_user",
- "issuer": "https://issuer.example.com",
- "suggested_display_name": "OIDC Display",
- "suggested_avatar_url": "https://cdn.example/oidc.png",
- }).
- SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
- Save(ctx)
- require.NoError(t, err)
- _, err = service.NewAuthPendingIdentityService(client).UpsertAdoptionDecision(ctx, service.PendingIdentityAdoptionDecisionInput{
- PendingAuthSessionID: session.ID,
- AdoptAvatar: true,
- })
- require.NoError(t, err)
- body := bytes.NewBufferString(`{"invitation_code":"invite-1","adopt_display_name":true}`)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/complete-registration", body)
- req.Header.Set("Content-Type", "application/json")
- req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
- req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("oidc-browser")})
- c.Request = req
- handler.CompleteOIDCOAuthRegistration(c)
- require.Equal(t, http.StatusOK, recorder.Code)
- responseData := decodeJSONBody(t, recorder)
- require.NotEmpty(t, responseData["access_token"])
- userEntity, err := client.User.Query().
- Where(dbuser.EmailEQ(session.ResolvedEmail)).
- Only(ctx)
- require.NoError(t, err)
- require.Equal(t, "OIDC Display", userEntity.Username)
- identity, err := client.AuthIdentity.Query().
- Where(
- authidentity.ProviderTypeEQ("oidc"),
- authidentity.ProviderKeyEQ("https://issuer.example.com"),
- authidentity.ProviderSubjectEQ("oidc-subject-1"),
- ).
- Only(ctx)
- require.NoError(t, err)
- require.Equal(t, userEntity.ID, identity.UserID)
- require.Equal(t, "OIDC Display", identity.Metadata["display_name"])
- require.Equal(t, "https://cdn.example/oidc.png", identity.Metadata["avatar_url"])
- decision, err := client.IdentityAdoptionDecision.Query().
- Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
- Only(ctx)
- require.NoError(t, err)
- require.NotNil(t, decision.IdentityID)
- require.Equal(t, identity.ID, *decision.IdentityID)
- require.True(t, decision.AdoptDisplayName)
- require.True(t, decision.AdoptAvatar)
- consumed, err := client.PendingAuthSession.Query().
- Where(pendingauthsession.IDEQ(session.ID)).
- Only(ctx)
- require.NoError(t, err)
- require.NotNil(t, consumed.ConsumedAt)
- }
- func TestCompleteOIDCOAuthRegistrationRejectsAdoptExistingUserSession(t *testing.T) {
- handler, client := newOAuthPendingFlowTestHandler(t, false)
- ctx := context.Background()
- existingUser, err := client.User.Create().
- SetEmail("owner@example.com").
- SetUsername("owner-user").
- SetPasswordHash("hash").
- SetRole(service.RoleUser).
- SetStatus(service.StatusActive).
- Save(ctx)
- require.NoError(t, err)
- session, err := client.PendingAuthSession.Create().
- SetSessionToken("oidc-complete-invalid-session").
- SetIntent("adopt_existing_user_by_email").
- SetProviderType("oidc").
- SetProviderKey("https://issuer.example.com").
- SetProviderSubject("oidc-invalid-subject-1").
- SetTargetUserID(existingUser.ID).
- SetResolvedEmail(existingUser.Email).
- SetBrowserSessionKey("oidc-invalid-browser").
- SetUpstreamIdentityClaims(map[string]any{
- "username": "oidc_user",
- }).
- SetLocalFlowState(map[string]any{
- oauthCompletionResponseKey: map[string]any{
- "step": "bind_login_required",
- },
- }).
- SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
- Save(ctx)
- require.NoError(t, err)
- body := bytes.NewBufferString(`{"invitation_code":"invite-1"}`)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/complete-registration", body)
- req.Header.Set("Content-Type", "application/json")
- req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
- req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("oidc-invalid-browser")})
- c.Request = req
- handler.CompleteOIDCOAuthRegistration(c)
- require.Equal(t, http.StatusBadRequest, recorder.Code)
- storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
- require.NoError(t, err)
- require.Nil(t, storedSession.ConsumedAt)
- }
- func TestCompleteOIDCOAuthRegistrationReturnsPendingSessionWhenChoiceStillRequired(t *testing.T) {
- handler, client := newOAuthPendingFlowTestHandler(t, false)
- ctx := context.Background()
- session, err := client.PendingAuthSession.Create().
- SetSessionToken("oidc-complete-choice-session").
- SetIntent("login").
- SetProviderType("oidc").
- SetProviderKey("https://issuer.example.com").
- SetProviderSubject("oidc-choice-subject-1").
- SetResolvedEmail("oidc-choice-subject-1@oidc-connect.invalid").
- SetBrowserSessionKey("oidc-choice-browser").
- SetUpstreamIdentityClaims(map[string]any{
- "username": "oidc_user",
- "issuer": "https://issuer.example.com",
- }).
- SetLocalFlowState(map[string]any{
- oauthCompletionResponseKey: map[string]any{
- "step": oauthPendingChoiceStep,
- "redirect": "/dashboard",
- "email": "fresh@example.com",
- "resolved_email": "fresh@example.com",
- "force_email_on_signup": true,
- },
- }).
- SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
- Save(ctx)
- require.NoError(t, err)
- body := bytes.NewBufferString(`{"invitation_code":"invite-1"}`)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/complete-registration", body)
- req.Header.Set("Content-Type", "application/json")
- req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
- req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("oidc-choice-browser")})
- c.Request = req
- handler.CompleteOIDCOAuthRegistration(c)
- require.Equal(t, http.StatusOK, recorder.Code)
- responseData := decodeJSONBody(t, recorder)
- require.Equal(t, "pending_session", responseData["auth_result"])
- require.Equal(t, oauthPendingChoiceStep, responseData["step"])
- require.Equal(t, true, responseData["force_email_on_signup"])
- require.Empty(t, responseData["access_token"])
- userCount, err := client.User.Query().Count(ctx)
- require.NoError(t, err)
- require.Zero(t, userCount)
- storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
- require.NoError(t, err)
- require.Nil(t, storedSession.ConsumedAt)
- }
- func TestCompleteOIDCOAuthRegistrationBindsIdentityWithoutAdoptionFlags(t *testing.T) {
- handler, client := newOAuthPendingFlowTestHandler(t, false)
- ctx := context.Background()
- session, err := client.PendingAuthSession.Create().
- SetSessionToken("oidc-complete-no-adoption-session").
- SetIntent("login").
- SetProviderType("oidc").
- SetProviderKey("https://issuer.example.com").
- SetProviderSubject("oidc-subject-no-adoption").
- SetResolvedEmail("8c9f12b2a2e14b1db9efc08b27e0ef5c@oidc-connect.invalid").
- SetBrowserSessionKey("oidc-browser-no-adoption").
- SetUpstreamIdentityClaims(map[string]any{
- "username": "oidc_user",
- "issuer": "https://issuer.example.com",
- "suggested_display_name": "OIDC Legacy",
- "suggested_avatar_url": "https://cdn.example/oidc-legacy.png",
- }).
- SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
- Save(ctx)
- require.NoError(t, err)
- body := bytes.NewBufferString(`{"invitation_code":"invite-1"}`)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/complete-registration", body)
- req.Header.Set("Content-Type", "application/json")
- req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
- req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("oidc-browser-no-adoption")})
- c.Request = req
- handler.CompleteOIDCOAuthRegistration(c)
- require.Equal(t, http.StatusOK, recorder.Code)
- responseData := decodeJSONBody(t, recorder)
- require.NotEmpty(t, responseData["access_token"])
- require.NotEmpty(t, responseData["refresh_token"])
- userEntity, err := client.User.Query().
- Where(dbuser.EmailEQ(session.ResolvedEmail)).
- Only(ctx)
- require.NoError(t, err)
- require.Equal(t, "oidc_user", userEntity.Username)
- identity, err := client.AuthIdentity.Query().
- Where(
- authidentity.ProviderTypeEQ("oidc"),
- authidentity.ProviderKeyEQ("https://issuer.example.com"),
- authidentity.ProviderSubjectEQ("oidc-subject-no-adoption"),
- ).
- Only(ctx)
- require.NoError(t, err)
- require.Equal(t, userEntity.ID, identity.UserID)
- decision, err := client.IdentityAdoptionDecision.Query().
- Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
- Only(ctx)
- require.NoError(t, err)
- require.NotNil(t, decision.IdentityID)
- require.Equal(t, identity.ID, *decision.IdentityID)
- require.False(t, decision.AdoptDisplayName)
- require.False(t, decision.AdoptAvatar)
- }
- func TestCompleteOIDCOAuthRegistrationRejectsIdentityOwnershipConflictBeforeUserCreation(t *testing.T) {
- handler, client := newOAuthPendingFlowTestHandler(t, false)
- ctx := context.Background()
- existingOwner, err := client.User.Create().
- SetEmail("owner@example.com").
- SetUsername("owner-user").
- SetPasswordHash("hash").
- SetRole(service.RoleUser).
- SetStatus(service.StatusActive).
- Save(ctx)
- require.NoError(t, err)
- _, err = client.AuthIdentity.Create().
- SetUserID(existingOwner.ID).
- SetProviderType("oidc").
- SetProviderKey("https://issuer.example.com").
- SetProviderSubject("oidc-conflict-subject").
- Save(ctx)
- require.NoError(t, err)
- session, err := client.PendingAuthSession.Create().
- SetSessionToken("oidc-complete-conflict-session").
- SetIntent("login").
- SetProviderType("oidc").
- SetProviderKey("https://issuer.example.com").
- SetProviderSubject("oidc-conflict-subject").
- SetResolvedEmail("f6f5f1f16f9248ccb11e0d633963b290@oidc-connect.invalid").
- SetBrowserSessionKey("oidc-conflict-browser").
- SetUpstreamIdentityClaims(map[string]any{
- "username": "oidc_user",
- "issuer": "https://issuer.example.com",
- }).
- SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
- Save(ctx)
- require.NoError(t, err)
- body := bytes.NewBufferString(`{"invitation_code":"invite-1"}`)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/complete-registration", body)
- req.Header.Set("Content-Type", "application/json")
- req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
- req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("oidc-conflict-browser")})
- c.Request = req
- handler.CompleteOIDCOAuthRegistration(c)
- require.Equal(t, http.StatusConflict, recorder.Code)
- payload := decodeJSONBody(t, recorder)
- require.Equal(t, "AUTH_IDENTITY_OWNERSHIP_CONFLICT", payload["reason"])
- userCount, err := client.User.Query().
- Where(dbuser.EmailEQ("f6f5f1f16f9248ccb11e0d633963b290@oidc-connect.invalid")).
- Count(ctx)
- require.NoError(t, err)
- require.Zero(t, userCount)
- storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
- require.NoError(t, err)
- require.Nil(t, storedSession.ConsumedAt)
- }
- func TestTryOIDCVerifiedEmailFastPathCreatesUserAndIdentity(t *testing.T) {
- handler, client := newOAuthPendingFlowTestHandler(t, false)
- t.Cleanup(func() { _ = client.Close() })
- ctx := context.Background()
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback", nil)
- identity := service.PendingAuthIdentityKey{
- ProviderType: "oidc",
- ProviderKey: "https://issuer.example.com",
- ProviderSubject: "fast-path-subject",
- }
- completed := handler.tryOIDCVerifiedEmailFastPath(
- c,
- "/auth/oidc/callback",
- "/dashboard",
- identity,
- "fastpath@example.com",
- "fastpath_user",
- map[string]any{
- "suggested_display_name": "Fast Path",
- "suggested_avatar_url": "",
- },
- )
- require.True(t, completed)
- require.Equal(t, http.StatusFound, recorder.Code)
- location := recorder.Header().Get("Location")
- require.Contains(t, location, "/auth/oidc/callback")
- require.Contains(t, location, "access_token=")
- require.Contains(t, location, "refresh_token=")
- require.Contains(t, location, "token_type=Bearer")
- user, err := client.User.Query().Where(dbuser.EmailEQ("fastpath@example.com")).Only(ctx)
- require.NoError(t, err)
- require.Equal(t, "fastpath_user", user.Username)
- require.Equal(t, "oidc", user.SignupSource)
- identityRecord, err := client.AuthIdentity.Query().Where(
- authidentity.ProviderTypeEQ("oidc"),
- authidentity.ProviderKeyEQ("https://issuer.example.com"),
- authidentity.ProviderSubjectEQ("fast-path-subject"),
- authidentity.UserIDEQ(user.ID),
- ).Only(ctx)
- require.NoError(t, err)
- require.Equal(t, "fastpath@example.com", identityRecord.Metadata["email"])
- require.Equal(t, true, identityRecord.Metadata["email_verified"])
- pendingCount, err := client.PendingAuthSession.Query().Count(ctx)
- require.NoError(t, err)
- require.Zero(t, pendingCount)
- }
- func TestOIDCOAuthCallbackVerifiedEmailFastPathIssuesTokenWithoutPendingSession(t *testing.T) {
- cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
- Subject: "oidc-fast-callback-subject",
- PreferredUsername: "oidc_fast_callback",
- DisplayName: "OIDC Fast Callback",
- AvatarURL: "https://cdn.example/oidc-fast.png",
- Email: "oidc-fast-callback@example.com",
- EmailVerified: true,
- })
- defer cleanup()
- handler, client := newOIDCOAuthHandlerAndClientWithSettings(t, false, cfg, nil)
- t.Cleanup(func() { _ = client.Close() })
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-fast-callback", nil)
- req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-fast-callback"))
- req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/dashboard"))
- req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-fast-callback"))
- req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-fast-callback-subject"))
- req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
- req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-fast-callback"))
- c.Request = req
- handler.OIDCOAuthCallback(c)
- require.Equal(t, http.StatusFound, recorder.Code)
- location := recorder.Header().Get("Location")
- require.Contains(t, location, "/auth/oidc/callback#")
- require.Contains(t, location, "access_token=")
- require.Contains(t, location, "refresh_token=")
- require.Contains(t, location, "token_type=Bearer")
- fragmentValues := parseOAuthRedirectFragment(t, location)
- require.Equal(t, "/dashboard", fragmentValues.Get("redirect"))
- requireCookieCleared(t, recorder, oauthPendingSessionCookieName)
- requireCookieCleared(t, recorder, oauthPendingBrowserCookieName)
- ctx := context.Background()
- user, err := client.User.Query().Where(dbuser.EmailEQ("oidc-fast-callback@example.com")).Only(ctx)
- require.NoError(t, err)
- require.Equal(t, "oidc_fast_callback", user.Username)
- require.Equal(t, "oidc", user.SignupSource)
- identity, err := client.AuthIdentity.Query().Where(
- authidentity.ProviderTypeEQ("oidc"),
- authidentity.ProviderKeyEQ(cfg.IssuerURL),
- authidentity.ProviderSubjectEQ("oidc-fast-callback-subject"),
- authidentity.UserIDEQ(user.ID),
- ).Only(ctx)
- require.NoError(t, err)
- require.Equal(t, "oidc-fast-callback@example.com", identity.Metadata["email"])
- require.Equal(t, true, identity.Metadata["email_verified"])
- require.Equal(t, "OIDC Fast Callback", identity.Metadata["suggested_display_name"])
- require.NotEqual(t, identity.Metadata["email"], identity.Metadata["synthetic_email"])
- pendingCount, err := client.PendingAuthSession.Query().Count(ctx)
- require.NoError(t, err)
- require.Zero(t, pendingCount)
- }
- func TestOIDCOAuthCallbackVerifiedEmailFastPathBackendModeBlocksBeforeUserCreation(t *testing.T) {
- cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
- Subject: "oidc-fast-backend-mode-subject",
- PreferredUsername: "oidc_backend_mode",
- DisplayName: "OIDC Backend Mode",
- Email: "oidc-backend-mode@example.com",
- EmailVerified: true,
- })
- defer cleanup()
- handler, client := newOIDCOAuthHandlerAndClientWithSettings(t, false, cfg, map[string]string{
- service.SettingKeyBackendModeEnabled: "true",
- })
- t.Cleanup(func() { _ = client.Close() })
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-backend-mode", nil)
- req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-backend-mode"))
- req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/dashboard"))
- req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-backend-mode"))
- req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-fast-backend-mode-subject"))
- req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
- req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-backend-mode"))
- c.Request = req
- handler.OIDCOAuthCallback(c)
- require.Equal(t, http.StatusFound, recorder.Code)
- assertOAuthRedirectError(t, recorder.Header().Get("Location"), "login_blocked", "BACKEND_MODE_ADMIN_ONLY")
- requireCookieCleared(t, recorder, oauthPendingSessionCookieName)
- requireCookieCleared(t, recorder, oauthPendingBrowserCookieName)
- ctx := context.Background()
- userCount, err := client.User.Query().Where(dbuser.EmailEQ("oidc-backend-mode@example.com")).Count(ctx)
- require.NoError(t, err)
- require.Zero(t, userCount)
- identityCount, err := client.AuthIdentity.Query().
- Where(authidentity.ProviderSubjectEQ("oidc-fast-backend-mode-subject")).
- Count(ctx)
- require.NoError(t, err)
- require.Zero(t, identityCount)
- pendingCount, err := client.PendingAuthSession.Query().Count(ctx)
- require.NoError(t, err)
- require.Zero(t, pendingCount)
- }
- func TestTryOIDCVerifiedEmailFastPathSkippedWhenInvitationCodeRequired(t *testing.T) {
- handler, client := newOAuthPendingFlowTestHandler(t, true)
- t.Cleanup(func() { _ = client.Close() })
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback", nil)
- identity := service.PendingAuthIdentityKey{
- ProviderType: "oidc",
- ProviderKey: "https://issuer.example.com",
- ProviderSubject: "fast-path-skipped-invitation",
- }
- completed := handler.tryOIDCVerifiedEmailFastPath(
- c,
- "/auth/oidc/callback",
- "/dashboard",
- identity,
- "invite-only@example.com",
- "invite_only_user",
- map[string]any{},
- )
- require.False(t, completed)
- require.NotEqual(t, http.StatusFound, recorder.Code)
- userCount, err := client.User.Query().Where(dbuser.EmailEQ("invite-only@example.com")).Count(context.Background())
- require.NoError(t, err)
- require.Zero(t, userCount)
- }
- func TestTryOIDCVerifiedEmailFastPathSkippedWhenForceEmailEnabled(t *testing.T) {
- handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
- settingValues: map[string]string{
- service.SettingKeyForceEmailOnThirdPartySignup: "true",
- },
- })
- t.Cleanup(func() { _ = client.Close() })
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback", nil)
- identity := service.PendingAuthIdentityKey{
- ProviderType: "oidc",
- ProviderKey: "https://issuer.example.com",
- ProviderSubject: "fast-path-skipped-force-email",
- }
- completed := handler.tryOIDCVerifiedEmailFastPath(
- c,
- "/auth/oidc/callback",
- "/dashboard",
- identity,
- "force-email@example.com",
- "force_email_user",
- map[string]any{},
- )
- require.False(t, completed)
- userCount, err := client.User.Query().Where(dbuser.EmailEQ("force-email@example.com")).Count(context.Background())
- require.NoError(t, err)
- require.Zero(t, userCount)
- }
- type oidcProviderFixture struct {
- Subject string
- PreferredUsername string
- DisplayName string
- AvatarURL string
- Email string
- EmailVerified bool
- }
- func newOIDCOAuthTestHandler(t *testing.T, invitationEnabled bool, oauthCfg config.OIDCConnectConfig) *AuthHandler {
- t.Helper()
- handler, _ := newOIDCOAuthHandlerAndClient(t, invitationEnabled, oauthCfg)
- return handler
- }
- func newOIDCOAuthHandlerAndClient(t *testing.T, invitationEnabled bool, oauthCfg config.OIDCConnectConfig) (*AuthHandler, *dbent.Client) {
- t.Helper()
- handler, client := newOAuthPendingFlowTestHandler(t, invitationEnabled)
- handler.settingSvc = nil
- handler.cfg = &config.Config{
- JWT: config.JWTConfig{
- Secret: "test-secret",
- ExpireHour: 1,
- AccessTokenExpireMinutes: 60,
- RefreshTokenExpireDays: 7,
- },
- OIDC: oauthCfg,
- }
- return handler, client
- }
- func newOIDCOAuthHandlerAndClientWithSettings(
- t *testing.T,
- invitationEnabled bool,
- oauthCfg config.OIDCConnectConfig,
- settingValues map[string]string,
- ) (*AuthHandler, *dbent.Client) {
- t.Helper()
- values := map[string]string{
- service.SettingKeyOIDCConnectEnabled: "true",
- service.SettingKeyOIDCConnectProviderName: strings.TrimSpace(firstNonEmpty(oauthCfg.ProviderName, "OIDC")),
- service.SettingKeyOIDCConnectClientID: oauthCfg.ClientID,
- service.SettingKeyOIDCConnectClientSecret: oauthCfg.ClientSecret,
- service.SettingKeyOIDCConnectIssuerURL: oauthCfg.IssuerURL,
- service.SettingKeyOIDCConnectAuthorizeURL: oauthCfg.AuthorizeURL,
- service.SettingKeyOIDCConnectTokenURL: oauthCfg.TokenURL,
- service.SettingKeyOIDCConnectUserInfoURL: oauthCfg.UserInfoURL,
- service.SettingKeyOIDCConnectJWKSURL: oauthCfg.JWKSURL,
- service.SettingKeyOIDCConnectScopes: oauthCfg.Scopes,
- service.SettingKeyOIDCConnectRedirectURL: oauthCfg.RedirectURL,
- service.SettingKeyOIDCConnectFrontendRedirectURL: oauthCfg.FrontendRedirectURL,
- service.SettingKeyOIDCConnectTokenAuthMethod: oauthCfg.TokenAuthMethod,
- service.SettingKeyOIDCConnectUsePKCE: boolSettingValue(oauthCfg.UsePKCE),
- service.SettingKeyOIDCConnectValidateIDToken: boolSettingValue(oauthCfg.ValidateIDToken),
- service.SettingKeyOIDCConnectAllowedSigningAlgs: oauthCfg.AllowedSigningAlgs,
- service.SettingKeyOIDCConnectClockSkewSeconds: "120",
- service.SettingKeyOIDCConnectRequireEmailVerified: boolSettingValue(oauthCfg.RequireEmailVerified),
- }
- for key, value := range settingValues {
- values[key] = value
- }
- handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
- invitationEnabled: invitationEnabled,
- settingValues: values,
- })
- if handler.cfg == nil {
- handler.cfg = &config.Config{}
- }
- handler.cfg.OIDC = oauthCfg
- return handler, client
- }
- func newOIDCTestProvider(t *testing.T, fixture oidcProviderFixture) (config.OIDCConnectConfig, func()) {
- t.Helper()
- privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
- require.NoError(t, err)
- kid := "test-kid"
- jwks := oidcJWKSet{Keys: []oidcJWK{buildRSAJWK(kid, &privateKey.PublicKey)}}
- tokenResponse := oidcTokenResponse{
- AccessToken: "oidc-access-token",
- TokenType: "Bearer",
- ExpiresIn: 3600,
- }
- userInfoPayload := map[string]any{
- "sub": fixture.Subject,
- "preferred_username": fixture.PreferredUsername,
- "name": fixture.DisplayName,
- "picture": fixture.AvatarURL,
- "email": fixture.Email,
- "email_verified": fixture.EmailVerified,
- }
- var issuer string
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/token":
- require.NoError(t, json.NewEncoder(w).Encode(tokenResponse))
- case "/userinfo":
- require.NoError(t, json.NewEncoder(w).Encode(userInfoPayload))
- case "/jwks":
- require.NoError(t, json.NewEncoder(w).Encode(jwks))
- default:
- http.NotFound(w, r)
- }
- }))
- issuer = server.URL
- now := time.Now()
- claims := oidcIDTokenClaims{
- Email: fixture.Email,
- EmailVerified: boolPtr(fixture.EmailVerified),
- PreferredUsername: fixture.PreferredUsername,
- Name: fixture.DisplayName,
- Nonce: "nonce-" + fixture.Subject,
- RegisteredClaims: jwt.RegisteredClaims{
- Issuer: issuer,
- Subject: fixture.Subject,
- Audience: jwt.ClaimStrings{"oidc-client"},
- IssuedAt: jwt.NewNumericDate(now),
- NotBefore: jwt.NewNumericDate(now.Add(-30 * time.Second)),
- ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
- },
- }
- token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
- token.Header["kid"] = kid
- tokenResponse.IDToken, err = token.SignedString(privateKey)
- require.NoError(t, err)
- cfg := config.OIDCConnectConfig{
- Enabled: true,
- ProviderName: "Test OIDC",
- ClientID: "oidc-client",
- ClientSecret: "oidc-secret",
- IssuerURL: issuer,
- AuthorizeURL: issuer + "/authorize",
- TokenURL: issuer + "/token",
- UserInfoURL: issuer + "/userinfo",
- JWKSURL: issuer + "/jwks",
- Scopes: "openid profile email",
- RedirectURL: "https://api.example.com/api/v1/auth/oauth/oidc/callback",
- FrontendRedirectURL: "/auth/oidc/callback",
- TokenAuthMethod: "client_secret_post",
- UsePKCE: true,
- ValidateIDToken: true,
- AllowedSigningAlgs: "RS256",
- ClockSkewSeconds: 120,
- RequireEmailVerified: false,
- }
- return cfg, server.Close
- }
|