auth_oidc_oauth_test.go 50 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310
  1. package handler
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/rand"
  6. "crypto/rsa"
  7. "encoding/base64"
  8. "encoding/json"
  9. "math/big"
  10. "net/http"
  11. "net/http/httptest"
  12. "strings"
  13. "testing"
  14. "time"
  15. dbent "github.com/Wei-Shaw/sub2api/ent"
  16. "github.com/Wei-Shaw/sub2api/ent/authidentity"
  17. "github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
  18. "github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
  19. dbuser "github.com/Wei-Shaw/sub2api/ent/user"
  20. "github.com/Wei-Shaw/sub2api/internal/config"
  21. servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  22. "github.com/Wei-Shaw/sub2api/internal/service"
  23. "github.com/gin-gonic/gin"
  24. "github.com/golang-jwt/jwt/v5"
  25. "github.com/stretchr/testify/require"
  26. )
  27. func TestOIDCSyntheticEmailStableAndDistinct(t *testing.T) {
  28. k1 := oidcIdentityKey("https://issuer.example.com", "subject-a")
  29. k2 := oidcIdentityKey("https://issuer.example.com", "subject-b")
  30. e1 := oidcSyntheticEmailFromIdentityKey(k1)
  31. e1Again := oidcSyntheticEmailFromIdentityKey(k1)
  32. e2 := oidcSyntheticEmailFromIdentityKey(k2)
  33. require.Equal(t, e1, e1Again)
  34. require.NotEqual(t, e1, e2)
  35. require.Contains(t, e1, "@oidc-connect.invalid")
  36. }
  37. func TestBuildOIDCAuthorizeURLIncludesNonceAndPKCE(t *testing.T) {
  38. cfg := config.OIDCConnectConfig{
  39. AuthorizeURL: "https://issuer.example.com/auth",
  40. ClientID: "cid",
  41. Scopes: "openid email profile",
  42. }
  43. u, err := buildOIDCAuthorizeURL(cfg, "state123", "nonce123", "challenge123", "https://app.example.com/callback")
  44. require.NoError(t, err)
  45. require.Contains(t, u, "nonce=nonce123")
  46. require.Contains(t, u, "code_challenge=challenge123")
  47. require.Contains(t, u, "code_challenge_method=S256")
  48. require.Contains(t, u, "scope=openid+email+profile")
  49. }
  50. func TestOIDCParseAndValidateIDToken(t *testing.T) {
  51. priv, err := rsa.GenerateKey(rand.Reader, 2048)
  52. require.NoError(t, err)
  53. kid := "kid-1"
  54. jwks := oidcJWKSet{Keys: []oidcJWK{buildRSAJWK(kid, &priv.PublicKey)}}
  55. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  56. require.NoError(t, json.NewEncoder(w).Encode(jwks))
  57. }))
  58. defer srv.Close()
  59. now := time.Now()
  60. claims := oidcIDTokenClaims{
  61. Nonce: "nonce-ok",
  62. Azp: "client-1",
  63. RegisteredClaims: jwt.RegisteredClaims{
  64. Issuer: "https://issuer.example.com",
  65. Subject: "subject-1",
  66. Audience: jwt.ClaimStrings{"client-1", "another-aud"},
  67. IssuedAt: jwt.NewNumericDate(now),
  68. NotBefore: jwt.NewNumericDate(now.Add(-30 * time.Second)),
  69. ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
  70. },
  71. }
  72. tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
  73. tok.Header["kid"] = kid
  74. signed, err := tok.SignedString(priv)
  75. require.NoError(t, err)
  76. cfg := config.OIDCConnectConfig{
  77. ClientID: "client-1",
  78. IssuerURL: "https://issuer.example.com",
  79. JWKSURL: srv.URL,
  80. AllowedSigningAlgs: "RS256",
  81. ClockSkewSeconds: 120,
  82. }
  83. parsed, err := oidcParseAndValidateIDToken(context.Background(), cfg, signed, "nonce-ok")
  84. require.NoError(t, err)
  85. require.Equal(t, "subject-1", parsed.Subject)
  86. require.Equal(t, "https://issuer.example.com", parsed.Issuer)
  87. _, err = oidcParseAndValidateIDToken(context.Background(), cfg, signed, "bad-nonce")
  88. require.Error(t, err)
  89. }
  90. func TestOIDCParseUserInfoIncludesSuggestedProfile(t *testing.T) {
  91. cfg := config.OIDCConnectConfig{}
  92. claims := oidcParseUserInfo(`{
  93. "sub":"subject-1",
  94. "preferred_username":"alice",
  95. "name":"Alice Example",
  96. "picture":"https://cdn.example/avatar.png",
  97. "email":"alice@example.com",
  98. "email_verified":true
  99. }`, cfg)
  100. require.Equal(t, "subject-1", claims.Subject)
  101. require.Equal(t, "alice", claims.Username)
  102. require.Equal(t, "Alice Example", claims.DisplayName)
  103. require.Equal(t, "https://cdn.example/avatar.png", claims.AvatarURL)
  104. require.NotNil(t, claims.EmailVerified)
  105. require.True(t, *claims.EmailVerified)
  106. }
  107. func buildRSAJWK(kid string, pub *rsa.PublicKey) oidcJWK {
  108. n := base64.RawURLEncoding.EncodeToString(pub.N.Bytes())
  109. e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes())
  110. return oidcJWK{
  111. Kty: "RSA",
  112. Kid: kid,
  113. Use: "sig",
  114. Alg: "RS256",
  115. N: n,
  116. E: e,
  117. }
  118. }
  119. func TestOIDCOAuthBindStartRedirectsAndSetsBindCookies(t *testing.T) {
  120. handler := newOIDCOAuthTestHandler(t, false, config.OIDCConnectConfig{
  121. Enabled: true,
  122. ClientID: "oidc-client",
  123. ClientSecret: "oidc-secret",
  124. IssuerURL: "https://issuer.example.com",
  125. AuthorizeURL: "https://issuer.example.com/oauth/authorize",
  126. TokenURL: "https://issuer.example.com/oauth/token",
  127. UserInfoURL: "https://issuer.example.com/oauth/userinfo",
  128. JWKSURL: "https://issuer.example.com/oauth/jwks",
  129. Scopes: "openid profile email",
  130. RedirectURL: "https://api.example.com/api/v1/auth/oauth/oidc/callback",
  131. FrontendRedirectURL: "/auth/oidc/callback",
  132. TokenAuthMethod: "client_secret_post",
  133. UsePKCE: true,
  134. ValidateIDToken: true,
  135. AllowedSigningAlgs: "RS256",
  136. ClockSkewSeconds: 120,
  137. RequireEmailVerified: false,
  138. })
  139. recorder := httptest.NewRecorder()
  140. c, _ := gin.CreateTestContext(recorder)
  141. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/bind/start?intent=bind_current_user&redirect=/settings/connections", nil)
  142. c.Request = req
  143. c.Set(string(servermiddleware.ContextKeyUser), servermiddleware.AuthSubject{UserID: 84})
  144. handler.OIDCOAuthStart(c)
  145. require.Equal(t, http.StatusFound, recorder.Code)
  146. location := recorder.Header().Get("Location")
  147. require.Contains(t, location, "issuer.example.com/oauth/authorize")
  148. require.Contains(t, location, "client_id=oidc-client")
  149. require.Contains(t, location, "nonce=")
  150. cookies := recorder.Result().Cookies()
  151. require.NotNil(t, findCookie(cookies, oidcOAuthStateCookieName))
  152. require.NotNil(t, findCookie(cookies, oidcOAuthRedirectCookie))
  153. require.NotNil(t, findCookie(cookies, oidcOAuthVerifierCookie))
  154. require.NotNil(t, findCookie(cookies, oidcOAuthNonceCookie))
  155. require.NotNil(t, findCookie(cookies, oauthPendingBrowserCookieName))
  156. intentCookie := findCookie(cookies, oidcOAuthIntentCookieName)
  157. require.NotNil(t, intentCookie)
  158. require.Equal(t, oauthIntentBindCurrentUser, decodeCookieValueForTest(t, intentCookie.Value))
  159. bindCookie := findCookie(cookies, oidcOAuthBindUserCookieName)
  160. require.NotNil(t, bindCookie)
  161. userID, err := parseOAuthBindUserCookieValue(decodeCookieValueForTest(t, bindCookie.Value), "test-secret")
  162. require.NoError(t, err)
  163. require.Equal(t, int64(84), userID)
  164. }
  165. func TestOIDCOAuthStartOmitsPKCEAndNonceWhenDisabled(t *testing.T) {
  166. handler := newOIDCOAuthTestHandler(t, false, config.OIDCConnectConfig{
  167. Enabled: true,
  168. ClientID: "oidc-client",
  169. ClientSecret: "oidc-secret",
  170. IssuerURL: "https://issuer.example.com",
  171. AuthorizeURL: "https://issuer.example.com/oauth/authorize",
  172. TokenURL: "https://issuer.example.com/oauth/token",
  173. UserInfoURL: "https://issuer.example.com/oauth/userinfo",
  174. Scopes: "openid profile email",
  175. RedirectURL: "https://api.example.com/api/v1/auth/oauth/oidc/callback",
  176. FrontendRedirectURL: "/auth/oidc/callback",
  177. TokenAuthMethod: "client_secret_post",
  178. UsePKCE: false,
  179. ValidateIDToken: false,
  180. RequireEmailVerified: false,
  181. })
  182. recorder := httptest.NewRecorder()
  183. c, _ := gin.CreateTestContext(recorder)
  184. c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/start?redirect=/dashboard", nil)
  185. handler.OIDCOAuthStart(c)
  186. require.Equal(t, http.StatusFound, recorder.Code)
  187. location := recorder.Header().Get("Location")
  188. require.NotContains(t, location, "code_challenge=")
  189. require.NotContains(t, location, "nonce=")
  190. require.Nil(t, findCookie(recorder.Result().Cookies(), oidcOAuthVerifierCookie))
  191. require.Nil(t, findCookie(recorder.Result().Cookies(), oidcOAuthNonceCookie))
  192. }
  193. func TestOIDCOAuthCallbackAllowsOptionalPKCEAndIDTokenValidation(t *testing.T) {
  194. upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  195. switch r.URL.Path {
  196. case "/token":
  197. require.NoError(t, r.ParseForm())
  198. require.Empty(t, r.PostForm.Get("code_verifier"))
  199. w.Header().Set("Content-Type", "application/json")
  200. _, _ = w.Write([]byte(`{"access_token":"oidc-access","token_type":"Bearer","expires_in":3600}`))
  201. case "/userinfo":
  202. w.Header().Set("Content-Type", "application/json")
  203. _, _ = w.Write([]byte(`{"sub":"oidc-subject-compat","preferred_username":"oidc_user","name":"OIDC Display","email":"oidc@example.com"}`))
  204. default:
  205. http.NotFound(w, r)
  206. }
  207. }))
  208. defer upstream.Close()
  209. handler, client := newOIDCOAuthHandlerAndClient(t, false, config.OIDCConnectConfig{
  210. Enabled: true,
  211. ClientID: "oidc-client",
  212. ClientSecret: "oidc-secret",
  213. IssuerURL: "https://issuer.example.com",
  214. AuthorizeURL: upstream.URL + "/authorize",
  215. TokenURL: upstream.URL + "/token",
  216. UserInfoURL: upstream.URL + "/userinfo",
  217. Scopes: "openid profile email",
  218. RedirectURL: "https://api.example.com/api/v1/auth/oauth/oidc/callback",
  219. FrontendRedirectURL: "/auth/oidc/callback",
  220. TokenAuthMethod: "client_secret_post",
  221. UsePKCE: false,
  222. ValidateIDToken: false,
  223. RequireEmailVerified: false,
  224. })
  225. t.Cleanup(func() { _ = client.Close() })
  226. recorder := httptest.NewRecorder()
  227. c, _ := gin.CreateTestContext(recorder)
  228. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-123", nil)
  229. req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-123"))
  230. req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/dashboard"))
  231. req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
  232. req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-123"))
  233. c.Request = req
  234. handler.OIDCOAuthCallback(c)
  235. require.Equal(t, http.StatusFound, recorder.Code)
  236. require.Equal(t, "/auth/oidc/callback", recorder.Header().Get("Location"))
  237. require.NotNil(t, findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName))
  238. }
  239. func TestOIDCOAuthCallbackCreatesLoginPendingSessionForExistingIdentityUser(t *testing.T) {
  240. cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
  241. Subject: "oidc-subject-login",
  242. PreferredUsername: "oidc_login",
  243. DisplayName: "OIDC Login Display",
  244. AvatarURL: "https://cdn.example/oidc-login.png",
  245. Email: "oidc-login@example.com",
  246. EmailVerified: true,
  247. })
  248. defer cleanup()
  249. handler, client := newOIDCOAuthHandlerAndClient(t, false, cfg)
  250. t.Cleanup(func() { _ = client.Close() })
  251. ctx := context.Background()
  252. existingUser, err := client.User.Create().
  253. SetEmail(oidcSyntheticEmailFromIdentityKey(oidcIdentityKey(cfg.IssuerURL, "oidc-subject-login"))).
  254. SetUsername("legacy-user").
  255. SetPasswordHash("hash").
  256. SetRole(service.RoleUser).
  257. SetStatus(service.StatusActive).
  258. Save(ctx)
  259. require.NoError(t, err)
  260. _, err = client.AuthIdentity.Create().
  261. SetUserID(existingUser.ID).
  262. SetProviderType("oidc").
  263. SetProviderKey(cfg.IssuerURL).
  264. SetProviderSubject("oidc-subject-login").
  265. SetMetadata(map[string]any{"username": "legacy-user"}).
  266. Save(ctx)
  267. require.NoError(t, err)
  268. recorder := httptest.NewRecorder()
  269. c, _ := gin.CreateTestContext(recorder)
  270. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-123", nil)
  271. req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-123"))
  272. req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/dashboard"))
  273. req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-123"))
  274. req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-subject-login"))
  275. req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
  276. req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-123"))
  277. c.Request = req
  278. handler.OIDCOAuthCallback(c)
  279. require.Equal(t, http.StatusFound, recorder.Code)
  280. require.Equal(t, "/auth/oidc/callback", recorder.Header().Get("Location"))
  281. sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
  282. require.NotNil(t, sessionCookie)
  283. session, err := client.PendingAuthSession.Query().
  284. Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
  285. Only(ctx)
  286. require.NoError(t, err)
  287. require.Equal(t, oauthIntentLogin, session.Intent)
  288. require.NotNil(t, session.TargetUserID)
  289. require.Equal(t, existingUser.ID, *session.TargetUserID)
  290. require.Equal(t, cfg.IssuerURL, session.ProviderKey)
  291. require.Equal(t, "OIDC Login Display", session.UpstreamIdentityClaims["suggested_display_name"])
  292. completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
  293. require.True(t, ok)
  294. require.Equal(t, "/dashboard", completion["redirect"])
  295. _, hasAccessToken := completion["access_token"]
  296. require.False(t, hasAccessToken)
  297. _, hasRefreshToken := completion["refresh_token"]
  298. require.False(t, hasRefreshToken)
  299. require.Nil(t, completion["error"])
  300. }
  301. func TestOIDCOAuthCallbackRejectsDisabledExistingIdentityUser(t *testing.T) {
  302. cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
  303. Subject: "oidc-disabled-subject",
  304. PreferredUsername: "oidc_disabled",
  305. DisplayName: "OIDC Disabled",
  306. })
  307. defer cleanup()
  308. handler, client := newOIDCOAuthHandlerAndClient(t, false, cfg)
  309. t.Cleanup(func() { _ = client.Close() })
  310. ctx := context.Background()
  311. existingUser, err := client.User.Create().
  312. SetEmail(oidcSyntheticEmailFromIdentityKey(oidcIdentityKey(cfg.IssuerURL, "oidc-disabled-subject"))).
  313. SetUsername("disabled-user").
  314. SetPasswordHash("hash").
  315. SetRole(service.RoleUser).
  316. SetStatus(service.StatusDisabled).
  317. Save(ctx)
  318. require.NoError(t, err)
  319. _, err = client.AuthIdentity.Create().
  320. SetUserID(existingUser.ID).
  321. SetProviderType("oidc").
  322. SetProviderKey(cfg.IssuerURL).
  323. SetProviderSubject("oidc-disabled-subject").
  324. Save(ctx)
  325. require.NoError(t, err)
  326. recorder := httptest.NewRecorder()
  327. c, _ := gin.CreateTestContext(recorder)
  328. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-disabled", nil)
  329. req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-disabled"))
  330. req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/dashboard"))
  331. req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-disabled"))
  332. req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-disabled-subject"))
  333. req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
  334. req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-disabled"))
  335. c.Request = req
  336. handler.OIDCOAuthCallback(c)
  337. require.Equal(t, http.StatusFound, recorder.Code)
  338. require.Nil(t, findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName))
  339. assertOAuthRedirectError(t, recorder.Header().Get("Location"), "session_error", "USER_NOT_ACTIVE")
  340. count, err := client.PendingAuthSession.Query().Count(ctx)
  341. require.NoError(t, err)
  342. require.Zero(t, count)
  343. }
  344. func TestOIDCOAuthCallbackCreatesBindPendingSessionForCompatEmailUser(t *testing.T) {
  345. cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
  346. Subject: "oidc-subject-compat",
  347. PreferredUsername: "oidc_compat",
  348. DisplayName: "OIDC Compat Display",
  349. AvatarURL: "https://cdn.example/oidc-compat.png",
  350. Email: "legacy@example.com",
  351. EmailVerified: true,
  352. })
  353. defer cleanup()
  354. handler, client := newOIDCOAuthHandlerAndClient(t, false, cfg)
  355. t.Cleanup(func() { _ = client.Close() })
  356. ctx := context.Background()
  357. existingUser, err := client.User.Create().
  358. SetEmail("legacy@example.com").
  359. SetUsername("legacy-user").
  360. SetPasswordHash("hash").
  361. SetRole(service.RoleUser).
  362. SetStatus(service.StatusActive).
  363. Save(ctx)
  364. require.NoError(t, err)
  365. recorder := httptest.NewRecorder()
  366. c, _ := gin.CreateTestContext(recorder)
  367. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-compat", nil)
  368. req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-compat"))
  369. req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/dashboard"))
  370. req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-compat"))
  371. req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-subject-compat"))
  372. req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
  373. req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-compat"))
  374. c.Request = req
  375. handler.OIDCOAuthCallback(c)
  376. require.Equal(t, http.StatusFound, recorder.Code)
  377. require.Equal(t, "/auth/oidc/callback", recorder.Header().Get("Location"))
  378. sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
  379. require.NotNil(t, sessionCookie)
  380. session, err := client.PendingAuthSession.Query().
  381. Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
  382. Only(ctx)
  383. require.NoError(t, err)
  384. require.Equal(t, oauthIntentLogin, session.Intent)
  385. require.NotNil(t, session.TargetUserID)
  386. require.Equal(t, existingUser.ID, *session.TargetUserID)
  387. require.Equal(t, existingUser.Email, session.ResolvedEmail)
  388. require.Equal(t, "legacy@example.com", session.UpstreamIdentityClaims["compat_email"])
  389. completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
  390. require.True(t, ok)
  391. require.Equal(t, "/dashboard", completion["redirect"])
  392. require.Equal(t, oauthPendingChoiceStep, completion["step"])
  393. require.Equal(t, existingUser.Email, completion["email"])
  394. require.Equal(t, existingUser.Email, completion["existing_account_email"])
  395. require.Equal(t, true, completion["existing_account_bindable"])
  396. require.Equal(t, "compat_email_match", completion["choice_reason"])
  397. _, hasAccessToken := completion["access_token"]
  398. require.False(t, hasAccessToken)
  399. }
  400. func TestOIDCOAuthCallbackAllowsCompatEmailBindWhenUpstreamEmailIsUnverified(t *testing.T) {
  401. cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
  402. Subject: "oidc-subject-unverified-compat",
  403. PreferredUsername: "oidc_unverified",
  404. DisplayName: "OIDC Unverified Compat Display",
  405. AvatarURL: "https://cdn.example/oidc-unverified.png",
  406. Email: "owner@example.com",
  407. EmailVerified: false,
  408. })
  409. defer cleanup()
  410. cfg.RequireEmailVerified = true
  411. handler, client := newOIDCOAuthHandlerAndClient(t, false, cfg)
  412. t.Cleanup(func() { _ = client.Close() })
  413. ctx := context.Background()
  414. _, err := client.User.Create().
  415. SetEmail("owner@example.com").
  416. SetUsername("owner-user").
  417. SetPasswordHash("hash").
  418. SetRole(service.RoleUser).
  419. SetStatus(service.StatusActive).
  420. Save(ctx)
  421. require.NoError(t, err)
  422. recorder := httptest.NewRecorder()
  423. c, _ := gin.CreateTestContext(recorder)
  424. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-unverified-compat", nil)
  425. req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-unverified-compat"))
  426. req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/settings/connections"))
  427. req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-unverified-compat"))
  428. req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-subject-unverified-compat"))
  429. req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
  430. req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-unverified-compat"))
  431. c.Request = req
  432. handler.OIDCOAuthCallback(c)
  433. require.Equal(t, http.StatusFound, recorder.Code)
  434. require.Equal(t, "/auth/oidc/callback#error=email_not_verified&error_message=email+is+not+verified", recorder.Header().Get("Location"))
  435. require.Nil(t, findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName))
  436. count, err := client.PendingAuthSession.Query().Count(ctx)
  437. require.NoError(t, err)
  438. require.Zero(t, count)
  439. }
  440. func TestOIDCOAuthCallbackCreatesChoicePendingSessionWhenSignupRequiresInvite(t *testing.T) {
  441. cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
  442. Subject: "oidc-subject-invite",
  443. PreferredUsername: "oidc_invite",
  444. DisplayName: "OIDC Invite Display",
  445. AvatarURL: "https://cdn.example/oidc-invite.png",
  446. Email: "oidc-invite@example.com",
  447. EmailVerified: true,
  448. })
  449. defer cleanup()
  450. handler, client := newOIDCOAuthHandlerAndClient(t, true, cfg)
  451. t.Cleanup(func() { _ = client.Close() })
  452. recorder := httptest.NewRecorder()
  453. c, _ := gin.CreateTestContext(recorder)
  454. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-456", nil)
  455. req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-456"))
  456. req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/dashboard"))
  457. req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-456"))
  458. req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-subject-invite"))
  459. req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
  460. req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-456"))
  461. c.Request = req
  462. handler.OIDCOAuthCallback(c)
  463. require.Equal(t, http.StatusFound, recorder.Code)
  464. require.Equal(t, "/auth/oidc/callback", recorder.Header().Get("Location"))
  465. sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
  466. require.NotNil(t, sessionCookie)
  467. ctx := context.Background()
  468. session, err := client.PendingAuthSession.Query().
  469. Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
  470. Only(ctx)
  471. require.NoError(t, err)
  472. require.Equal(t, oauthIntentLogin, session.Intent)
  473. require.Nil(t, session.TargetUserID)
  474. completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
  475. require.True(t, ok)
  476. require.Equal(t, oauthPendingChoiceStep, completion["step"])
  477. require.Equal(t, "/dashboard", completion["redirect"])
  478. require.Equal(t, "third_party_signup", completion["choice_reason"])
  479. }
  480. func TestOIDCOAuthCallbackCreatesBindPendingSessionForCurrentUser(t *testing.T) {
  481. cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
  482. Subject: "oidc-subject-bind",
  483. PreferredUsername: "oidc_bind",
  484. DisplayName: "OIDC Bind Display",
  485. AvatarURL: "https://cdn.example/oidc-bind.png",
  486. Email: "oidc-bind@example.com",
  487. EmailVerified: true,
  488. })
  489. defer cleanup()
  490. handler, client := newOIDCOAuthHandlerAndClient(t, false, cfg)
  491. t.Cleanup(func() { _ = client.Close() })
  492. ctx := context.Background()
  493. currentUser, err := client.User.Create().
  494. SetEmail("current@example.com").
  495. SetUsername("current-user").
  496. SetPasswordHash("hash").
  497. SetRole(service.RoleUser).
  498. SetStatus(service.StatusActive).
  499. Save(ctx)
  500. require.NoError(t, err)
  501. recorder := httptest.NewRecorder()
  502. c, _ := gin.CreateTestContext(recorder)
  503. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-bind", nil)
  504. req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-bind"))
  505. req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/settings/connections"))
  506. req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-bind"))
  507. req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-subject-bind"))
  508. req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentBindCurrentUser))
  509. req.AddCookie(encodedCookie(oidcOAuthBindUserCookieName, buildEncodedOAuthBindUserCookie(t, currentUser.ID, "test-secret")))
  510. req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-bind"))
  511. c.Request = req
  512. handler.OIDCOAuthCallback(c)
  513. require.Equal(t, http.StatusFound, recorder.Code)
  514. require.Equal(t, "/auth/oidc/callback", recorder.Header().Get("Location"))
  515. sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
  516. require.NotNil(t, sessionCookie)
  517. session, err := client.PendingAuthSession.Query().
  518. Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
  519. Only(ctx)
  520. require.NoError(t, err)
  521. require.Equal(t, oauthIntentBindCurrentUser, session.Intent)
  522. require.NotNil(t, session.TargetUserID)
  523. require.Equal(t, currentUser.ID, *session.TargetUserID)
  524. require.Equal(t, cfg.IssuerURL, session.ProviderKey)
  525. require.Equal(t, "OIDC Bind Display", session.UpstreamIdentityClaims["suggested_display_name"])
  526. completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
  527. require.True(t, ok)
  528. require.Equal(t, "/settings/connections", completion["redirect"])
  529. require.Empty(t, completion["access_token"])
  530. userCount, err := client.User.Query().Count(ctx)
  531. require.NoError(t, err)
  532. require.Equal(t, 1, userCount)
  533. }
  534. func TestCompleteOIDCOAuthRegistrationAppliesPendingAdoptionDecision(t *testing.T) {
  535. handler, client := newOAuthPendingFlowTestHandler(t, false)
  536. ctx := context.Background()
  537. session, err := client.PendingAuthSession.Create().
  538. SetSessionToken("oidc-complete-session").
  539. SetIntent("login").
  540. SetProviderType("oidc").
  541. SetProviderKey("https://issuer.example.com").
  542. SetProviderSubject("oidc-subject-1").
  543. SetResolvedEmail("93a310f4c1944c5bbd2e246df1f76485@oidc-connect.invalid").
  544. SetBrowserSessionKey("oidc-browser").
  545. SetUpstreamIdentityClaims(map[string]any{
  546. "username": "oidc_user",
  547. "issuer": "https://issuer.example.com",
  548. "suggested_display_name": "OIDC Display",
  549. "suggested_avatar_url": "https://cdn.example/oidc.png",
  550. }).
  551. SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
  552. Save(ctx)
  553. require.NoError(t, err)
  554. _, err = service.NewAuthPendingIdentityService(client).UpsertAdoptionDecision(ctx, service.PendingIdentityAdoptionDecisionInput{
  555. PendingAuthSessionID: session.ID,
  556. AdoptAvatar: true,
  557. })
  558. require.NoError(t, err)
  559. body := bytes.NewBufferString(`{"invitation_code":"invite-1","adopt_display_name":true}`)
  560. recorder := httptest.NewRecorder()
  561. c, _ := gin.CreateTestContext(recorder)
  562. req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/complete-registration", body)
  563. req.Header.Set("Content-Type", "application/json")
  564. req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
  565. req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("oidc-browser")})
  566. c.Request = req
  567. handler.CompleteOIDCOAuthRegistration(c)
  568. require.Equal(t, http.StatusOK, recorder.Code)
  569. responseData := decodeJSONBody(t, recorder)
  570. require.NotEmpty(t, responseData["access_token"])
  571. userEntity, err := client.User.Query().
  572. Where(dbuser.EmailEQ(session.ResolvedEmail)).
  573. Only(ctx)
  574. require.NoError(t, err)
  575. require.Equal(t, "OIDC Display", userEntity.Username)
  576. identity, err := client.AuthIdentity.Query().
  577. Where(
  578. authidentity.ProviderTypeEQ("oidc"),
  579. authidentity.ProviderKeyEQ("https://issuer.example.com"),
  580. authidentity.ProviderSubjectEQ("oidc-subject-1"),
  581. ).
  582. Only(ctx)
  583. require.NoError(t, err)
  584. require.Equal(t, userEntity.ID, identity.UserID)
  585. require.Equal(t, "OIDC Display", identity.Metadata["display_name"])
  586. require.Equal(t, "https://cdn.example/oidc.png", identity.Metadata["avatar_url"])
  587. decision, err := client.IdentityAdoptionDecision.Query().
  588. Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
  589. Only(ctx)
  590. require.NoError(t, err)
  591. require.NotNil(t, decision.IdentityID)
  592. require.Equal(t, identity.ID, *decision.IdentityID)
  593. require.True(t, decision.AdoptDisplayName)
  594. require.True(t, decision.AdoptAvatar)
  595. consumed, err := client.PendingAuthSession.Query().
  596. Where(pendingauthsession.IDEQ(session.ID)).
  597. Only(ctx)
  598. require.NoError(t, err)
  599. require.NotNil(t, consumed.ConsumedAt)
  600. }
  601. func TestCompleteOIDCOAuthRegistrationRejectsAdoptExistingUserSession(t *testing.T) {
  602. handler, client := newOAuthPendingFlowTestHandler(t, false)
  603. ctx := context.Background()
  604. existingUser, err := client.User.Create().
  605. SetEmail("owner@example.com").
  606. SetUsername("owner-user").
  607. SetPasswordHash("hash").
  608. SetRole(service.RoleUser).
  609. SetStatus(service.StatusActive).
  610. Save(ctx)
  611. require.NoError(t, err)
  612. session, err := client.PendingAuthSession.Create().
  613. SetSessionToken("oidc-complete-invalid-session").
  614. SetIntent("adopt_existing_user_by_email").
  615. SetProviderType("oidc").
  616. SetProviderKey("https://issuer.example.com").
  617. SetProviderSubject("oidc-invalid-subject-1").
  618. SetTargetUserID(existingUser.ID).
  619. SetResolvedEmail(existingUser.Email).
  620. SetBrowserSessionKey("oidc-invalid-browser").
  621. SetUpstreamIdentityClaims(map[string]any{
  622. "username": "oidc_user",
  623. }).
  624. SetLocalFlowState(map[string]any{
  625. oauthCompletionResponseKey: map[string]any{
  626. "step": "bind_login_required",
  627. },
  628. }).
  629. SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
  630. Save(ctx)
  631. require.NoError(t, err)
  632. body := bytes.NewBufferString(`{"invitation_code":"invite-1"}`)
  633. recorder := httptest.NewRecorder()
  634. c, _ := gin.CreateTestContext(recorder)
  635. req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/complete-registration", body)
  636. req.Header.Set("Content-Type", "application/json")
  637. req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
  638. req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("oidc-invalid-browser")})
  639. c.Request = req
  640. handler.CompleteOIDCOAuthRegistration(c)
  641. require.Equal(t, http.StatusBadRequest, recorder.Code)
  642. storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
  643. require.NoError(t, err)
  644. require.Nil(t, storedSession.ConsumedAt)
  645. }
  646. func TestCompleteOIDCOAuthRegistrationReturnsPendingSessionWhenChoiceStillRequired(t *testing.T) {
  647. handler, client := newOAuthPendingFlowTestHandler(t, false)
  648. ctx := context.Background()
  649. session, err := client.PendingAuthSession.Create().
  650. SetSessionToken("oidc-complete-choice-session").
  651. SetIntent("login").
  652. SetProviderType("oidc").
  653. SetProviderKey("https://issuer.example.com").
  654. SetProviderSubject("oidc-choice-subject-1").
  655. SetResolvedEmail("oidc-choice-subject-1@oidc-connect.invalid").
  656. SetBrowserSessionKey("oidc-choice-browser").
  657. SetUpstreamIdentityClaims(map[string]any{
  658. "username": "oidc_user",
  659. "issuer": "https://issuer.example.com",
  660. }).
  661. SetLocalFlowState(map[string]any{
  662. oauthCompletionResponseKey: map[string]any{
  663. "step": oauthPendingChoiceStep,
  664. "redirect": "/dashboard",
  665. "email": "fresh@example.com",
  666. "resolved_email": "fresh@example.com",
  667. "force_email_on_signup": true,
  668. },
  669. }).
  670. SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
  671. Save(ctx)
  672. require.NoError(t, err)
  673. body := bytes.NewBufferString(`{"invitation_code":"invite-1"}`)
  674. recorder := httptest.NewRecorder()
  675. c, _ := gin.CreateTestContext(recorder)
  676. req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/complete-registration", body)
  677. req.Header.Set("Content-Type", "application/json")
  678. req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
  679. req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("oidc-choice-browser")})
  680. c.Request = req
  681. handler.CompleteOIDCOAuthRegistration(c)
  682. require.Equal(t, http.StatusOK, recorder.Code)
  683. responseData := decodeJSONBody(t, recorder)
  684. require.Equal(t, "pending_session", responseData["auth_result"])
  685. require.Equal(t, oauthPendingChoiceStep, responseData["step"])
  686. require.Equal(t, true, responseData["force_email_on_signup"])
  687. require.Empty(t, responseData["access_token"])
  688. userCount, err := client.User.Query().Count(ctx)
  689. require.NoError(t, err)
  690. require.Zero(t, userCount)
  691. storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
  692. require.NoError(t, err)
  693. require.Nil(t, storedSession.ConsumedAt)
  694. }
  695. func TestCompleteOIDCOAuthRegistrationBindsIdentityWithoutAdoptionFlags(t *testing.T) {
  696. handler, client := newOAuthPendingFlowTestHandler(t, false)
  697. ctx := context.Background()
  698. session, err := client.PendingAuthSession.Create().
  699. SetSessionToken("oidc-complete-no-adoption-session").
  700. SetIntent("login").
  701. SetProviderType("oidc").
  702. SetProviderKey("https://issuer.example.com").
  703. SetProviderSubject("oidc-subject-no-adoption").
  704. SetResolvedEmail("8c9f12b2a2e14b1db9efc08b27e0ef5c@oidc-connect.invalid").
  705. SetBrowserSessionKey("oidc-browser-no-adoption").
  706. SetUpstreamIdentityClaims(map[string]any{
  707. "username": "oidc_user",
  708. "issuer": "https://issuer.example.com",
  709. "suggested_display_name": "OIDC Legacy",
  710. "suggested_avatar_url": "https://cdn.example/oidc-legacy.png",
  711. }).
  712. SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
  713. Save(ctx)
  714. require.NoError(t, err)
  715. body := bytes.NewBufferString(`{"invitation_code":"invite-1"}`)
  716. recorder := httptest.NewRecorder()
  717. c, _ := gin.CreateTestContext(recorder)
  718. req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/complete-registration", body)
  719. req.Header.Set("Content-Type", "application/json")
  720. req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
  721. req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("oidc-browser-no-adoption")})
  722. c.Request = req
  723. handler.CompleteOIDCOAuthRegistration(c)
  724. require.Equal(t, http.StatusOK, recorder.Code)
  725. responseData := decodeJSONBody(t, recorder)
  726. require.NotEmpty(t, responseData["access_token"])
  727. require.NotEmpty(t, responseData["refresh_token"])
  728. userEntity, err := client.User.Query().
  729. Where(dbuser.EmailEQ(session.ResolvedEmail)).
  730. Only(ctx)
  731. require.NoError(t, err)
  732. require.Equal(t, "oidc_user", userEntity.Username)
  733. identity, err := client.AuthIdentity.Query().
  734. Where(
  735. authidentity.ProviderTypeEQ("oidc"),
  736. authidentity.ProviderKeyEQ("https://issuer.example.com"),
  737. authidentity.ProviderSubjectEQ("oidc-subject-no-adoption"),
  738. ).
  739. Only(ctx)
  740. require.NoError(t, err)
  741. require.Equal(t, userEntity.ID, identity.UserID)
  742. decision, err := client.IdentityAdoptionDecision.Query().
  743. Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
  744. Only(ctx)
  745. require.NoError(t, err)
  746. require.NotNil(t, decision.IdentityID)
  747. require.Equal(t, identity.ID, *decision.IdentityID)
  748. require.False(t, decision.AdoptDisplayName)
  749. require.False(t, decision.AdoptAvatar)
  750. }
  751. func TestCompleteOIDCOAuthRegistrationRejectsIdentityOwnershipConflictBeforeUserCreation(t *testing.T) {
  752. handler, client := newOAuthPendingFlowTestHandler(t, false)
  753. ctx := context.Background()
  754. existingOwner, err := client.User.Create().
  755. SetEmail("owner@example.com").
  756. SetUsername("owner-user").
  757. SetPasswordHash("hash").
  758. SetRole(service.RoleUser).
  759. SetStatus(service.StatusActive).
  760. Save(ctx)
  761. require.NoError(t, err)
  762. _, err = client.AuthIdentity.Create().
  763. SetUserID(existingOwner.ID).
  764. SetProviderType("oidc").
  765. SetProviderKey("https://issuer.example.com").
  766. SetProviderSubject("oidc-conflict-subject").
  767. Save(ctx)
  768. require.NoError(t, err)
  769. session, err := client.PendingAuthSession.Create().
  770. SetSessionToken("oidc-complete-conflict-session").
  771. SetIntent("login").
  772. SetProviderType("oidc").
  773. SetProviderKey("https://issuer.example.com").
  774. SetProviderSubject("oidc-conflict-subject").
  775. SetResolvedEmail("f6f5f1f16f9248ccb11e0d633963b290@oidc-connect.invalid").
  776. SetBrowserSessionKey("oidc-conflict-browser").
  777. SetUpstreamIdentityClaims(map[string]any{
  778. "username": "oidc_user",
  779. "issuer": "https://issuer.example.com",
  780. }).
  781. SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
  782. Save(ctx)
  783. require.NoError(t, err)
  784. body := bytes.NewBufferString(`{"invitation_code":"invite-1"}`)
  785. recorder := httptest.NewRecorder()
  786. c, _ := gin.CreateTestContext(recorder)
  787. req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/complete-registration", body)
  788. req.Header.Set("Content-Type", "application/json")
  789. req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
  790. req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("oidc-conflict-browser")})
  791. c.Request = req
  792. handler.CompleteOIDCOAuthRegistration(c)
  793. require.Equal(t, http.StatusConflict, recorder.Code)
  794. payload := decodeJSONBody(t, recorder)
  795. require.Equal(t, "AUTH_IDENTITY_OWNERSHIP_CONFLICT", payload["reason"])
  796. userCount, err := client.User.Query().
  797. Where(dbuser.EmailEQ("f6f5f1f16f9248ccb11e0d633963b290@oidc-connect.invalid")).
  798. Count(ctx)
  799. require.NoError(t, err)
  800. require.Zero(t, userCount)
  801. storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
  802. require.NoError(t, err)
  803. require.Nil(t, storedSession.ConsumedAt)
  804. }
  805. func TestTryOIDCVerifiedEmailFastPathCreatesUserAndIdentity(t *testing.T) {
  806. handler, client := newOAuthPendingFlowTestHandler(t, false)
  807. t.Cleanup(func() { _ = client.Close() })
  808. ctx := context.Background()
  809. recorder := httptest.NewRecorder()
  810. c, _ := gin.CreateTestContext(recorder)
  811. c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback", nil)
  812. identity := service.PendingAuthIdentityKey{
  813. ProviderType: "oidc",
  814. ProviderKey: "https://issuer.example.com",
  815. ProviderSubject: "fast-path-subject",
  816. }
  817. completed := handler.tryOIDCVerifiedEmailFastPath(
  818. c,
  819. "/auth/oidc/callback",
  820. "/dashboard",
  821. identity,
  822. "fastpath@example.com",
  823. "fastpath_user",
  824. map[string]any{
  825. "suggested_display_name": "Fast Path",
  826. "suggested_avatar_url": "",
  827. },
  828. )
  829. require.True(t, completed)
  830. require.Equal(t, http.StatusFound, recorder.Code)
  831. location := recorder.Header().Get("Location")
  832. require.Contains(t, location, "/auth/oidc/callback")
  833. require.Contains(t, location, "access_token=")
  834. require.Contains(t, location, "refresh_token=")
  835. require.Contains(t, location, "token_type=Bearer")
  836. user, err := client.User.Query().Where(dbuser.EmailEQ("fastpath@example.com")).Only(ctx)
  837. require.NoError(t, err)
  838. require.Equal(t, "fastpath_user", user.Username)
  839. require.Equal(t, "oidc", user.SignupSource)
  840. identityRecord, err := client.AuthIdentity.Query().Where(
  841. authidentity.ProviderTypeEQ("oidc"),
  842. authidentity.ProviderKeyEQ("https://issuer.example.com"),
  843. authidentity.ProviderSubjectEQ("fast-path-subject"),
  844. authidentity.UserIDEQ(user.ID),
  845. ).Only(ctx)
  846. require.NoError(t, err)
  847. require.Equal(t, "fastpath@example.com", identityRecord.Metadata["email"])
  848. require.Equal(t, true, identityRecord.Metadata["email_verified"])
  849. pendingCount, err := client.PendingAuthSession.Query().Count(ctx)
  850. require.NoError(t, err)
  851. require.Zero(t, pendingCount)
  852. }
  853. func TestOIDCOAuthCallbackVerifiedEmailFastPathIssuesTokenWithoutPendingSession(t *testing.T) {
  854. cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
  855. Subject: "oidc-fast-callback-subject",
  856. PreferredUsername: "oidc_fast_callback",
  857. DisplayName: "OIDC Fast Callback",
  858. AvatarURL: "https://cdn.example/oidc-fast.png",
  859. Email: "oidc-fast-callback@example.com",
  860. EmailVerified: true,
  861. })
  862. defer cleanup()
  863. handler, client := newOIDCOAuthHandlerAndClientWithSettings(t, false, cfg, nil)
  864. t.Cleanup(func() { _ = client.Close() })
  865. recorder := httptest.NewRecorder()
  866. c, _ := gin.CreateTestContext(recorder)
  867. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-fast-callback", nil)
  868. req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-fast-callback"))
  869. req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/dashboard"))
  870. req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-fast-callback"))
  871. req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-fast-callback-subject"))
  872. req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
  873. req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-fast-callback"))
  874. c.Request = req
  875. handler.OIDCOAuthCallback(c)
  876. require.Equal(t, http.StatusFound, recorder.Code)
  877. location := recorder.Header().Get("Location")
  878. require.Contains(t, location, "/auth/oidc/callback#")
  879. require.Contains(t, location, "access_token=")
  880. require.Contains(t, location, "refresh_token=")
  881. require.Contains(t, location, "token_type=Bearer")
  882. fragmentValues := parseOAuthRedirectFragment(t, location)
  883. require.Equal(t, "/dashboard", fragmentValues.Get("redirect"))
  884. requireCookieCleared(t, recorder, oauthPendingSessionCookieName)
  885. requireCookieCleared(t, recorder, oauthPendingBrowserCookieName)
  886. ctx := context.Background()
  887. user, err := client.User.Query().Where(dbuser.EmailEQ("oidc-fast-callback@example.com")).Only(ctx)
  888. require.NoError(t, err)
  889. require.Equal(t, "oidc_fast_callback", user.Username)
  890. require.Equal(t, "oidc", user.SignupSource)
  891. identity, err := client.AuthIdentity.Query().Where(
  892. authidentity.ProviderTypeEQ("oidc"),
  893. authidentity.ProviderKeyEQ(cfg.IssuerURL),
  894. authidentity.ProviderSubjectEQ("oidc-fast-callback-subject"),
  895. authidentity.UserIDEQ(user.ID),
  896. ).Only(ctx)
  897. require.NoError(t, err)
  898. require.Equal(t, "oidc-fast-callback@example.com", identity.Metadata["email"])
  899. require.Equal(t, true, identity.Metadata["email_verified"])
  900. require.Equal(t, "OIDC Fast Callback", identity.Metadata["suggested_display_name"])
  901. require.NotEqual(t, identity.Metadata["email"], identity.Metadata["synthetic_email"])
  902. pendingCount, err := client.PendingAuthSession.Query().Count(ctx)
  903. require.NoError(t, err)
  904. require.Zero(t, pendingCount)
  905. }
  906. func TestOIDCOAuthCallbackVerifiedEmailFastPathBackendModeBlocksBeforeUserCreation(t *testing.T) {
  907. cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
  908. Subject: "oidc-fast-backend-mode-subject",
  909. PreferredUsername: "oidc_backend_mode",
  910. DisplayName: "OIDC Backend Mode",
  911. Email: "oidc-backend-mode@example.com",
  912. EmailVerified: true,
  913. })
  914. defer cleanup()
  915. handler, client := newOIDCOAuthHandlerAndClientWithSettings(t, false, cfg, map[string]string{
  916. service.SettingKeyBackendModeEnabled: "true",
  917. })
  918. t.Cleanup(func() { _ = client.Close() })
  919. recorder := httptest.NewRecorder()
  920. c, _ := gin.CreateTestContext(recorder)
  921. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-backend-mode", nil)
  922. req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-backend-mode"))
  923. req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/dashboard"))
  924. req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-backend-mode"))
  925. req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-fast-backend-mode-subject"))
  926. req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
  927. req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-backend-mode"))
  928. c.Request = req
  929. handler.OIDCOAuthCallback(c)
  930. require.Equal(t, http.StatusFound, recorder.Code)
  931. assertOAuthRedirectError(t, recorder.Header().Get("Location"), "login_blocked", "BACKEND_MODE_ADMIN_ONLY")
  932. requireCookieCleared(t, recorder, oauthPendingSessionCookieName)
  933. requireCookieCleared(t, recorder, oauthPendingBrowserCookieName)
  934. ctx := context.Background()
  935. userCount, err := client.User.Query().Where(dbuser.EmailEQ("oidc-backend-mode@example.com")).Count(ctx)
  936. require.NoError(t, err)
  937. require.Zero(t, userCount)
  938. identityCount, err := client.AuthIdentity.Query().
  939. Where(authidentity.ProviderSubjectEQ("oidc-fast-backend-mode-subject")).
  940. Count(ctx)
  941. require.NoError(t, err)
  942. require.Zero(t, identityCount)
  943. pendingCount, err := client.PendingAuthSession.Query().Count(ctx)
  944. require.NoError(t, err)
  945. require.Zero(t, pendingCount)
  946. }
  947. func TestTryOIDCVerifiedEmailFastPathSkippedWhenInvitationCodeRequired(t *testing.T) {
  948. handler, client := newOAuthPendingFlowTestHandler(t, true)
  949. t.Cleanup(func() { _ = client.Close() })
  950. recorder := httptest.NewRecorder()
  951. c, _ := gin.CreateTestContext(recorder)
  952. c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback", nil)
  953. identity := service.PendingAuthIdentityKey{
  954. ProviderType: "oidc",
  955. ProviderKey: "https://issuer.example.com",
  956. ProviderSubject: "fast-path-skipped-invitation",
  957. }
  958. completed := handler.tryOIDCVerifiedEmailFastPath(
  959. c,
  960. "/auth/oidc/callback",
  961. "/dashboard",
  962. identity,
  963. "invite-only@example.com",
  964. "invite_only_user",
  965. map[string]any{},
  966. )
  967. require.False(t, completed)
  968. require.NotEqual(t, http.StatusFound, recorder.Code)
  969. userCount, err := client.User.Query().Where(dbuser.EmailEQ("invite-only@example.com")).Count(context.Background())
  970. require.NoError(t, err)
  971. require.Zero(t, userCount)
  972. }
  973. func TestTryOIDCVerifiedEmailFastPathSkippedWhenForceEmailEnabled(t *testing.T) {
  974. handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
  975. settingValues: map[string]string{
  976. service.SettingKeyForceEmailOnThirdPartySignup: "true",
  977. },
  978. })
  979. t.Cleanup(func() { _ = client.Close() })
  980. recorder := httptest.NewRecorder()
  981. c, _ := gin.CreateTestContext(recorder)
  982. c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback", nil)
  983. identity := service.PendingAuthIdentityKey{
  984. ProviderType: "oidc",
  985. ProviderKey: "https://issuer.example.com",
  986. ProviderSubject: "fast-path-skipped-force-email",
  987. }
  988. completed := handler.tryOIDCVerifiedEmailFastPath(
  989. c,
  990. "/auth/oidc/callback",
  991. "/dashboard",
  992. identity,
  993. "force-email@example.com",
  994. "force_email_user",
  995. map[string]any{},
  996. )
  997. require.False(t, completed)
  998. userCount, err := client.User.Query().Where(dbuser.EmailEQ("force-email@example.com")).Count(context.Background())
  999. require.NoError(t, err)
  1000. require.Zero(t, userCount)
  1001. }
  1002. type oidcProviderFixture struct {
  1003. Subject string
  1004. PreferredUsername string
  1005. DisplayName string
  1006. AvatarURL string
  1007. Email string
  1008. EmailVerified bool
  1009. }
  1010. func newOIDCOAuthTestHandler(t *testing.T, invitationEnabled bool, oauthCfg config.OIDCConnectConfig) *AuthHandler {
  1011. t.Helper()
  1012. handler, _ := newOIDCOAuthHandlerAndClient(t, invitationEnabled, oauthCfg)
  1013. return handler
  1014. }
  1015. func newOIDCOAuthHandlerAndClient(t *testing.T, invitationEnabled bool, oauthCfg config.OIDCConnectConfig) (*AuthHandler, *dbent.Client) {
  1016. t.Helper()
  1017. handler, client := newOAuthPendingFlowTestHandler(t, invitationEnabled)
  1018. handler.settingSvc = nil
  1019. handler.cfg = &config.Config{
  1020. JWT: config.JWTConfig{
  1021. Secret: "test-secret",
  1022. ExpireHour: 1,
  1023. AccessTokenExpireMinutes: 60,
  1024. RefreshTokenExpireDays: 7,
  1025. },
  1026. OIDC: oauthCfg,
  1027. }
  1028. return handler, client
  1029. }
  1030. func newOIDCOAuthHandlerAndClientWithSettings(
  1031. t *testing.T,
  1032. invitationEnabled bool,
  1033. oauthCfg config.OIDCConnectConfig,
  1034. settingValues map[string]string,
  1035. ) (*AuthHandler, *dbent.Client) {
  1036. t.Helper()
  1037. values := map[string]string{
  1038. service.SettingKeyOIDCConnectEnabled: "true",
  1039. service.SettingKeyOIDCConnectProviderName: strings.TrimSpace(firstNonEmpty(oauthCfg.ProviderName, "OIDC")),
  1040. service.SettingKeyOIDCConnectClientID: oauthCfg.ClientID,
  1041. service.SettingKeyOIDCConnectClientSecret: oauthCfg.ClientSecret,
  1042. service.SettingKeyOIDCConnectIssuerURL: oauthCfg.IssuerURL,
  1043. service.SettingKeyOIDCConnectAuthorizeURL: oauthCfg.AuthorizeURL,
  1044. service.SettingKeyOIDCConnectTokenURL: oauthCfg.TokenURL,
  1045. service.SettingKeyOIDCConnectUserInfoURL: oauthCfg.UserInfoURL,
  1046. service.SettingKeyOIDCConnectJWKSURL: oauthCfg.JWKSURL,
  1047. service.SettingKeyOIDCConnectScopes: oauthCfg.Scopes,
  1048. service.SettingKeyOIDCConnectRedirectURL: oauthCfg.RedirectURL,
  1049. service.SettingKeyOIDCConnectFrontendRedirectURL: oauthCfg.FrontendRedirectURL,
  1050. service.SettingKeyOIDCConnectTokenAuthMethod: oauthCfg.TokenAuthMethod,
  1051. service.SettingKeyOIDCConnectUsePKCE: boolSettingValue(oauthCfg.UsePKCE),
  1052. service.SettingKeyOIDCConnectValidateIDToken: boolSettingValue(oauthCfg.ValidateIDToken),
  1053. service.SettingKeyOIDCConnectAllowedSigningAlgs: oauthCfg.AllowedSigningAlgs,
  1054. service.SettingKeyOIDCConnectClockSkewSeconds: "120",
  1055. service.SettingKeyOIDCConnectRequireEmailVerified: boolSettingValue(oauthCfg.RequireEmailVerified),
  1056. }
  1057. for key, value := range settingValues {
  1058. values[key] = value
  1059. }
  1060. handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
  1061. invitationEnabled: invitationEnabled,
  1062. settingValues: values,
  1063. })
  1064. if handler.cfg == nil {
  1065. handler.cfg = &config.Config{}
  1066. }
  1067. handler.cfg.OIDC = oauthCfg
  1068. return handler, client
  1069. }
  1070. func newOIDCTestProvider(t *testing.T, fixture oidcProviderFixture) (config.OIDCConnectConfig, func()) {
  1071. t.Helper()
  1072. privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
  1073. require.NoError(t, err)
  1074. kid := "test-kid"
  1075. jwks := oidcJWKSet{Keys: []oidcJWK{buildRSAJWK(kid, &privateKey.PublicKey)}}
  1076. tokenResponse := oidcTokenResponse{
  1077. AccessToken: "oidc-access-token",
  1078. TokenType: "Bearer",
  1079. ExpiresIn: 3600,
  1080. }
  1081. userInfoPayload := map[string]any{
  1082. "sub": fixture.Subject,
  1083. "preferred_username": fixture.PreferredUsername,
  1084. "name": fixture.DisplayName,
  1085. "picture": fixture.AvatarURL,
  1086. "email": fixture.Email,
  1087. "email_verified": fixture.EmailVerified,
  1088. }
  1089. var issuer string
  1090. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  1091. switch r.URL.Path {
  1092. case "/token":
  1093. require.NoError(t, json.NewEncoder(w).Encode(tokenResponse))
  1094. case "/userinfo":
  1095. require.NoError(t, json.NewEncoder(w).Encode(userInfoPayload))
  1096. case "/jwks":
  1097. require.NoError(t, json.NewEncoder(w).Encode(jwks))
  1098. default:
  1099. http.NotFound(w, r)
  1100. }
  1101. }))
  1102. issuer = server.URL
  1103. now := time.Now()
  1104. claims := oidcIDTokenClaims{
  1105. Email: fixture.Email,
  1106. EmailVerified: boolPtr(fixture.EmailVerified),
  1107. PreferredUsername: fixture.PreferredUsername,
  1108. Name: fixture.DisplayName,
  1109. Nonce: "nonce-" + fixture.Subject,
  1110. RegisteredClaims: jwt.RegisteredClaims{
  1111. Issuer: issuer,
  1112. Subject: fixture.Subject,
  1113. Audience: jwt.ClaimStrings{"oidc-client"},
  1114. IssuedAt: jwt.NewNumericDate(now),
  1115. NotBefore: jwt.NewNumericDate(now.Add(-30 * time.Second)),
  1116. ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
  1117. },
  1118. }
  1119. token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
  1120. token.Header["kid"] = kid
  1121. tokenResponse.IDToken, err = token.SignedString(privateKey)
  1122. require.NoError(t, err)
  1123. cfg := config.OIDCConnectConfig{
  1124. Enabled: true,
  1125. ProviderName: "Test OIDC",
  1126. ClientID: "oidc-client",
  1127. ClientSecret: "oidc-secret",
  1128. IssuerURL: issuer,
  1129. AuthorizeURL: issuer + "/authorize",
  1130. TokenURL: issuer + "/token",
  1131. UserInfoURL: issuer + "/userinfo",
  1132. JWKSURL: issuer + "/jwks",
  1133. Scopes: "openid profile email",
  1134. RedirectURL: "https://api.example.com/api/v1/auth/oauth/oidc/callback",
  1135. FrontendRedirectURL: "/auth/oidc/callback",
  1136. TokenAuthMethod: "client_secret_post",
  1137. UsePKCE: true,
  1138. ValidateIDToken: true,
  1139. AllowedSigningAlgs: "RS256",
  1140. ClockSkewSeconds: 120,
  1141. RequireEmailVerified: false,
  1142. }
  1143. return cfg, server.Close
  1144. }