auth_linuxdo_oauth_test.go 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236
  1. package handler
  2. import (
  3. "bytes"
  4. "context"
  5. "net/http"
  6. "net/http/httptest"
  7. "net/url"
  8. "strings"
  9. "testing"
  10. "time"
  11. dbent "github.com/Wei-Shaw/sub2api/ent"
  12. "github.com/Wei-Shaw/sub2api/ent/authidentity"
  13. "github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
  14. "github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
  15. dbuser "github.com/Wei-Shaw/sub2api/ent/user"
  16. "github.com/Wei-Shaw/sub2api/internal/config"
  17. servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  18. "github.com/Wei-Shaw/sub2api/internal/service"
  19. "github.com/gin-gonic/gin"
  20. "github.com/stretchr/testify/require"
  21. )
  22. func TestSanitizeFrontendRedirectPath(t *testing.T) {
  23. require.Equal(t, "/dashboard", sanitizeFrontendRedirectPath("/dashboard"))
  24. require.Equal(t, "/dashboard", sanitizeFrontendRedirectPath(" /dashboard "))
  25. require.Equal(t, "", sanitizeFrontendRedirectPath("dashboard"))
  26. require.Equal(t, "", sanitizeFrontendRedirectPath("//evil.com"))
  27. require.Equal(t, "", sanitizeFrontendRedirectPath("https://evil.com"))
  28. require.Equal(t, "", sanitizeFrontendRedirectPath("/\nfoo"))
  29. long := "/" + strings.Repeat("a", linuxDoOAuthMaxRedirectLen)
  30. require.Equal(t, "", sanitizeFrontendRedirectPath(long))
  31. }
  32. func TestBuildBearerAuthorization(t *testing.T) {
  33. auth, err := buildBearerAuthorization("", "token123")
  34. require.NoError(t, err)
  35. require.Equal(t, "Bearer token123", auth)
  36. auth, err = buildBearerAuthorization("bearer", "token123")
  37. require.NoError(t, err)
  38. require.Equal(t, "Bearer token123", auth)
  39. _, err = buildBearerAuthorization("MAC", "token123")
  40. require.Error(t, err)
  41. _, err = buildBearerAuthorization("Bearer", "token 123")
  42. require.Error(t, err)
  43. }
  44. func TestLinuxDoParseUserInfoParsesIDAndUsername(t *testing.T) {
  45. cfg := config.LinuxDoConnectConfig{
  46. UserInfoURL: "https://connect.linux.do/api/user",
  47. }
  48. email, username, subject, displayName, avatarURL, err := linuxDoParseUserInfo(`{"id":123,"username":"alice","name":"Alice","avatar_url":"https://cdn.example/avatar.png"}`, cfg)
  49. require.NoError(t, err)
  50. require.Equal(t, "123", subject)
  51. require.Equal(t, "alice", username)
  52. require.Equal(t, "linuxdo-123@linuxdo-connect.invalid", email)
  53. require.Equal(t, "Alice", displayName)
  54. require.Equal(t, "https://cdn.example/avatar.png", avatarURL)
  55. }
  56. func TestLinuxDoParseUserInfoDefaultsUsername(t *testing.T) {
  57. cfg := config.LinuxDoConnectConfig{
  58. UserInfoURL: "https://connect.linux.do/api/user",
  59. }
  60. email, username, subject, displayName, avatarURL, err := linuxDoParseUserInfo(`{"id":"123"}`, cfg)
  61. require.NoError(t, err)
  62. require.Equal(t, "123", subject)
  63. require.Equal(t, "linuxdo_123", username)
  64. require.Equal(t, "linuxdo-123@linuxdo-connect.invalid", email)
  65. require.Equal(t, "linuxdo_123", displayName)
  66. require.Equal(t, "", avatarURL)
  67. }
  68. func TestLinuxDoParseUserInfoRejectsUnsafeSubject(t *testing.T) {
  69. cfg := config.LinuxDoConnectConfig{
  70. UserInfoURL: "https://connect.linux.do/api/user",
  71. }
  72. _, _, _, _, _, err := linuxDoParseUserInfo(`{"id":"123@456"}`, cfg)
  73. require.Error(t, err)
  74. tooLong := strings.Repeat("a", linuxDoOAuthMaxSubjectLen+1)
  75. _, _, _, _, _, err = linuxDoParseUserInfo(`{"id":"`+tooLong+`"}`, cfg)
  76. require.Error(t, err)
  77. }
  78. func TestParseOAuthProviderErrorJSON(t *testing.T) {
  79. code, desc := parseOAuthProviderError(`{"error":"invalid_client","error_description":"bad secret"}`)
  80. require.Equal(t, "invalid_client", code)
  81. require.Equal(t, "bad secret", desc)
  82. }
  83. func TestParseOAuthProviderErrorForm(t *testing.T) {
  84. code, desc := parseOAuthProviderError("error=invalid_request&error_description=Missing+code_verifier")
  85. require.Equal(t, "invalid_request", code)
  86. require.Equal(t, "Missing code_verifier", desc)
  87. }
  88. func TestParseLinuxDoTokenResponseJSON(t *testing.T) {
  89. token, ok := parseLinuxDoTokenResponse(`{"access_token":"t1","token_type":"Bearer","expires_in":3600,"scope":"user"}`)
  90. require.True(t, ok)
  91. require.Equal(t, "t1", token.AccessToken)
  92. require.Equal(t, "Bearer", token.TokenType)
  93. require.Equal(t, int64(3600), token.ExpiresIn)
  94. require.Equal(t, "user", token.Scope)
  95. }
  96. func TestParseLinuxDoTokenResponseForm(t *testing.T) {
  97. token, ok := parseLinuxDoTokenResponse("access_token=t2&token_type=bearer&expires_in=60")
  98. require.True(t, ok)
  99. require.Equal(t, "t2", token.AccessToken)
  100. require.Equal(t, "bearer", token.TokenType)
  101. require.Equal(t, int64(60), token.ExpiresIn)
  102. }
  103. func TestSingleLineStripsWhitespace(t *testing.T) {
  104. require.Equal(t, "hello world", singleLine("hello\r\nworld"))
  105. require.Equal(t, "", singleLine("\n\t\r"))
  106. }
  107. func TestLinuxDoOAuthBindStartRedirectsAndSetsBindCookies(t *testing.T) {
  108. handler := newLinuxDoOAuthTestHandler(t, false, config.LinuxDoConnectConfig{
  109. Enabled: true,
  110. ClientID: "linuxdo-client",
  111. ClientSecret: "linuxdo-secret",
  112. AuthorizeURL: "https://connect.linux.do/oauth/authorize",
  113. TokenURL: "https://connect.linux.do/oauth/token",
  114. UserInfoURL: "https://connect.linux.do/api/user",
  115. Scopes: "read",
  116. RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
  117. FrontendRedirectURL: "/auth/linuxdo/callback",
  118. TokenAuthMethod: "client_secret_post",
  119. UsePKCE: true,
  120. })
  121. recorder := httptest.NewRecorder()
  122. c, _ := gin.CreateTestContext(recorder)
  123. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/bind/start?intent=bind_current_user&redirect=/settings/connections", nil)
  124. c.Request = req
  125. c.Set(string(servermiddleware.ContextKeyUser), servermiddleware.AuthSubject{UserID: 42})
  126. handler.LinuxDoOAuthStart(c)
  127. require.Equal(t, http.StatusFound, recorder.Code)
  128. location := recorder.Header().Get("Location")
  129. require.Contains(t, location, "connect.linux.do/oauth/authorize")
  130. require.Contains(t, location, "client_id=linuxdo-client")
  131. require.Contains(t, location, "code_challenge=")
  132. cookies := recorder.Result().Cookies()
  133. require.NotNil(t, findCookie(cookies, linuxDoOAuthStateCookieName))
  134. require.NotNil(t, findCookie(cookies, linuxDoOAuthRedirectCookie))
  135. require.NotNil(t, findCookie(cookies, linuxDoOAuthVerifierCookie))
  136. require.NotNil(t, findCookie(cookies, oauthPendingBrowserCookieName))
  137. intentCookie := findCookie(cookies, linuxDoOAuthIntentCookieName)
  138. require.NotNil(t, intentCookie)
  139. require.Equal(t, oauthIntentBindCurrentUser, decodeCookieValueForTest(t, intentCookie.Value))
  140. bindCookie := findCookie(cookies, linuxDoOAuthBindUserCookieName)
  141. require.NotNil(t, bindCookie)
  142. userID, err := parseOAuthBindUserCookieValue(decodeCookieValueForTest(t, bindCookie.Value), "test-secret")
  143. require.NoError(t, err)
  144. require.Equal(t, int64(42), userID)
  145. }
  146. func TestLinuxDoOAuthStartOmitsPKCEWhenDisabled(t *testing.T) {
  147. handler := newLinuxDoOAuthTestHandler(t, false, config.LinuxDoConnectConfig{
  148. Enabled: true,
  149. ClientID: "linuxdo-client",
  150. ClientSecret: "linuxdo-secret",
  151. AuthorizeURL: "https://connect.linux.do/oauth/authorize",
  152. TokenURL: "https://connect.linux.do/oauth/token",
  153. UserInfoURL: "https://connect.linux.do/api/user",
  154. Scopes: "read",
  155. RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
  156. FrontendRedirectURL: "/auth/linuxdo/callback",
  157. TokenAuthMethod: "client_secret_post",
  158. UsePKCE: false,
  159. })
  160. recorder := httptest.NewRecorder()
  161. c, _ := gin.CreateTestContext(recorder)
  162. c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/start?redirect=/dashboard", nil)
  163. handler.LinuxDoOAuthStart(c)
  164. require.Equal(t, http.StatusFound, recorder.Code)
  165. require.NotContains(t, recorder.Header().Get("Location"), "code_challenge=")
  166. require.Nil(t, findCookie(recorder.Result().Cookies(), linuxDoOAuthVerifierCookie))
  167. }
  168. func TestLinuxDoOAuthCallbackAllowsMissingVerifierWhenPKCEDisabled(t *testing.T) {
  169. upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  170. switch r.URL.Path {
  171. case "/token":
  172. require.NoError(t, r.ParseForm())
  173. require.Empty(t, r.PostForm.Get("code_verifier"))
  174. w.Header().Set("Content-Type", "application/json")
  175. _, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
  176. case "/userinfo":
  177. w.Header().Set("Content-Type", "application/json")
  178. _, _ = w.Write([]byte(`{"id":"compat-subject","username":"linuxdo_user","name":"LinuxDo Display"}`))
  179. default:
  180. http.NotFound(w, r)
  181. }
  182. }))
  183. defer upstream.Close()
  184. handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
  185. Enabled: true,
  186. ClientID: "linuxdo-client",
  187. ClientSecret: "linuxdo-secret",
  188. AuthorizeURL: upstream.URL + "/authorize",
  189. TokenURL: upstream.URL + "/token",
  190. UserInfoURL: upstream.URL + "/userinfo",
  191. Scopes: "read",
  192. RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
  193. FrontendRedirectURL: "/auth/linuxdo/callback",
  194. TokenAuthMethod: "client_secret_post",
  195. UsePKCE: false,
  196. })
  197. t.Cleanup(func() { _ = client.Close() })
  198. recorder := httptest.NewRecorder()
  199. c, _ := gin.CreateTestContext(recorder)
  200. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=linuxdo-code&state=state-123", nil)
  201. req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-123"))
  202. req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
  203. req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
  204. req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-123"))
  205. c.Request = req
  206. handler.LinuxDoOAuthCallback(c)
  207. require.Equal(t, http.StatusFound, recorder.Code)
  208. location := recorder.Header().Get("Location")
  209. require.Contains(t, location, "/auth/linuxdo/callback#")
  210. require.Contains(t, location, "access_token=")
  211. requireCookieCleared(t, recorder, oauthPendingSessionCookieName)
  212. identity, err := client.AuthIdentity.Query().
  213. Where(
  214. authidentity.ProviderTypeEQ("linuxdo"),
  215. authidentity.ProviderKeyEQ("linuxdo"),
  216. authidentity.ProviderSubjectEQ("compat-subject"),
  217. ).
  218. Only(context.Background())
  219. require.NoError(t, err)
  220. require.Positive(t, identity.UserID)
  221. }
  222. func TestLinuxDoOAuthBindStartAcceptsAccessTokenCookie(t *testing.T) {
  223. handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
  224. Enabled: true,
  225. ClientID: "linuxdo-client",
  226. ClientSecret: "linuxdo-secret",
  227. AuthorizeURL: "https://connect.linux.do/oauth/authorize",
  228. TokenURL: "https://connect.linux.do/oauth/token",
  229. UserInfoURL: "https://connect.linux.do/api/user",
  230. Scopes: "read",
  231. RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
  232. FrontendRedirectURL: "/auth/linuxdo/callback",
  233. TokenAuthMethod: "client_secret_post",
  234. UsePKCE: true,
  235. })
  236. t.Cleanup(func() { _ = client.Close() })
  237. user, err := client.User.Create().
  238. SetEmail("bind-cookie@example.com").
  239. SetUsername("bind-cookie-user").
  240. SetPasswordHash("hash").
  241. SetRole(service.RoleUser).
  242. SetStatus(service.StatusActive).
  243. Save(context.Background())
  244. require.NoError(t, err)
  245. token, err := handler.authService.GenerateToken(context.Background(), &service.User{
  246. ID: user.ID,
  247. Email: user.Email,
  248. Username: user.Username,
  249. PasswordHash: user.PasswordHash,
  250. Role: user.Role,
  251. Status: user.Status,
  252. })
  253. require.NoError(t, err)
  254. recorder := httptest.NewRecorder()
  255. c, _ := gin.CreateTestContext(recorder)
  256. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/start?intent=bind_current_user&redirect=/settings/connections", nil)
  257. req.AddCookie(&http.Cookie{Name: oauthBindAccessTokenCookieName, Value: token, Path: oauthBindAccessTokenCookiePath})
  258. c.Request = req
  259. handler.LinuxDoOAuthStart(c)
  260. require.Equal(t, http.StatusFound, recorder.Code)
  261. bindCookie := findCookie(recorder.Result().Cookies(), linuxDoOAuthBindUserCookieName)
  262. require.NotNil(t, bindCookie)
  263. userID, err := parseOAuthBindUserCookieValue(decodeCookieValueForTest(t, bindCookie.Value), "test-secret")
  264. require.NoError(t, err)
  265. require.Equal(t, user.ID, userID)
  266. accessTokenCookie := findCookie(recorder.Result().Cookies(), oauthBindAccessTokenCookieName)
  267. require.NotNil(t, accessTokenCookie)
  268. require.Equal(t, -1, accessTokenCookie.MaxAge)
  269. }
  270. func TestPrepareOAuthBindAccessTokenCookieSetsHttpOnlyCookie(t *testing.T) {
  271. handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{})
  272. t.Cleanup(func() { _ = client.Close() })
  273. recorder := httptest.NewRecorder()
  274. c, _ := gin.CreateTestContext(recorder)
  275. req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/bind-token", nil)
  276. req.Header.Set("Authorization", "Bearer access-token-value")
  277. c.Request = req
  278. handler.PrepareOAuthBindAccessTokenCookie(c)
  279. require.Equal(t, http.StatusNoContent, recorder.Code)
  280. accessTokenCookie := findCookie(recorder.Result().Cookies(), oauthBindAccessTokenCookieName)
  281. require.NotNil(t, accessTokenCookie)
  282. require.Equal(t, oauthBindAccessTokenCookiePath, accessTokenCookie.Path)
  283. require.Equal(t, linuxDoOAuthCookieMaxAgeSec, accessTokenCookie.MaxAge)
  284. require.True(t, accessTokenCookie.HttpOnly)
  285. require.Equal(t, url.QueryEscape("access-token-value"), accessTokenCookie.Value)
  286. }
  287. func TestLinuxDoOAuthCallbackCreatesLoginPendingSessionForExistingIdentityUser(t *testing.T) {
  288. upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  289. switch r.URL.Path {
  290. case "/token":
  291. w.Header().Set("Content-Type", "application/json")
  292. _, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
  293. case "/userinfo":
  294. w.Header().Set("Content-Type", "application/json")
  295. _, _ = w.Write([]byte(`{"id":"321","username":"linuxdo_user","name":"LinuxDo Display","avatar_url":"https://cdn.example/linuxdo.png"}`))
  296. default:
  297. http.NotFound(w, r)
  298. }
  299. }))
  300. defer upstream.Close()
  301. handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
  302. Enabled: true,
  303. ClientID: "linuxdo-client",
  304. ClientSecret: "linuxdo-secret",
  305. AuthorizeURL: upstream.URL + "/authorize",
  306. TokenURL: upstream.URL + "/token",
  307. UserInfoURL: upstream.URL + "/userinfo",
  308. Scopes: "read",
  309. RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
  310. FrontendRedirectURL: "/auth/linuxdo/callback",
  311. TokenAuthMethod: "client_secret_post",
  312. UsePKCE: true,
  313. })
  314. t.Cleanup(func() { _ = client.Close() })
  315. ctx := context.Background()
  316. existingUser, err := client.User.Create().
  317. SetEmail(linuxDoSyntheticEmail("321")).
  318. SetUsername("legacy-user").
  319. SetPasswordHash("hash").
  320. SetRole(service.RoleUser).
  321. SetStatus(service.StatusActive).
  322. Save(ctx)
  323. require.NoError(t, err)
  324. _, err = client.AuthIdentity.Create().
  325. SetUserID(existingUser.ID).
  326. SetProviderType("linuxdo").
  327. SetProviderKey("linuxdo").
  328. SetProviderSubject("321").
  329. SetMetadata(map[string]any{"username": "legacy-user"}).
  330. Save(ctx)
  331. require.NoError(t, err)
  332. recorder := httptest.NewRecorder()
  333. c, _ := gin.CreateTestContext(recorder)
  334. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-123&state=state-123", nil)
  335. req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-123"))
  336. req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
  337. req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-123"))
  338. req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
  339. req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-123"))
  340. c.Request = req
  341. handler.LinuxDoOAuthCallback(c)
  342. require.Equal(t, http.StatusFound, recorder.Code)
  343. require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
  344. sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
  345. require.NotNil(t, sessionCookie)
  346. session, err := client.PendingAuthSession.Query().
  347. Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
  348. Only(ctx)
  349. require.NoError(t, err)
  350. require.Equal(t, oauthIntentLogin, session.Intent)
  351. require.NotNil(t, session.TargetUserID)
  352. require.Equal(t, existingUser.ID, *session.TargetUserID)
  353. require.Equal(t, linuxDoSyntheticEmail("321"), session.ResolvedEmail)
  354. require.Equal(t, "LinuxDo Display", session.UpstreamIdentityClaims["suggested_display_name"])
  355. completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
  356. require.True(t, ok)
  357. require.Equal(t, "/dashboard", completion["redirect"])
  358. _, hasAccessToken := completion["access_token"]
  359. require.False(t, hasAccessToken)
  360. _, hasRefreshToken := completion["refresh_token"]
  361. require.False(t, hasRefreshToken)
  362. require.Nil(t, completion["error"])
  363. }
  364. func TestLinuxDoOAuthCallbackRejectsDisabledExistingIdentityUser(t *testing.T) {
  365. upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  366. switch r.URL.Path {
  367. case "/token":
  368. w.Header().Set("Content-Type", "application/json")
  369. _, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
  370. case "/userinfo":
  371. w.Header().Set("Content-Type", "application/json")
  372. _, _ = w.Write([]byte(`{"id":"654","username":"linuxdo_disabled","name":"LinuxDo Disabled"}`))
  373. default:
  374. http.NotFound(w, r)
  375. }
  376. }))
  377. defer upstream.Close()
  378. handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
  379. Enabled: true,
  380. ClientID: "linuxdo-client",
  381. ClientSecret: "linuxdo-secret",
  382. AuthorizeURL: upstream.URL + "/authorize",
  383. TokenURL: upstream.URL + "/token",
  384. UserInfoURL: upstream.URL + "/userinfo",
  385. Scopes: "read",
  386. RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
  387. FrontendRedirectURL: "/auth/linuxdo/callback",
  388. TokenAuthMethod: "client_secret_post",
  389. UsePKCE: true,
  390. })
  391. t.Cleanup(func() { _ = client.Close() })
  392. ctx := context.Background()
  393. existingUser, err := client.User.Create().
  394. SetEmail(linuxDoSyntheticEmail("654")).
  395. SetUsername("disabled-user").
  396. SetPasswordHash("hash").
  397. SetRole(service.RoleUser).
  398. SetStatus(service.StatusDisabled).
  399. Save(ctx)
  400. require.NoError(t, err)
  401. _, err = client.AuthIdentity.Create().
  402. SetUserID(existingUser.ID).
  403. SetProviderType("linuxdo").
  404. SetProviderKey("linuxdo").
  405. SetProviderSubject("654").
  406. Save(ctx)
  407. require.NoError(t, err)
  408. recorder := httptest.NewRecorder()
  409. c, _ := gin.CreateTestContext(recorder)
  410. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-disabled&state=state-disabled", nil)
  411. req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-disabled"))
  412. req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
  413. req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-disabled"))
  414. req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
  415. req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-disabled"))
  416. c.Request = req
  417. handler.LinuxDoOAuthCallback(c)
  418. require.Equal(t, http.StatusFound, recorder.Code)
  419. require.Nil(t, findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName))
  420. assertOAuthRedirectError(t, recorder.Header().Get("Location"), "session_error", "USER_NOT_ACTIVE")
  421. count, err := client.PendingAuthSession.Query().Count(ctx)
  422. require.NoError(t, err)
  423. require.Zero(t, count)
  424. }
  425. func TestLinuxDoOAuthCallbackCreatesBindPendingSessionForCompatEmailUser(t *testing.T) {
  426. upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  427. switch r.URL.Path {
  428. case "/token":
  429. w.Header().Set("Content-Type", "application/json")
  430. _, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
  431. case "/userinfo":
  432. w.Header().Set("Content-Type", "application/json")
  433. _, _ = w.Write([]byte(`{"id":"321","email":"legacy@example.com","username":"linuxdo_user","name":"LinuxDo Display","avatar_url":"https://cdn.example/linuxdo.png"}`))
  434. default:
  435. http.NotFound(w, r)
  436. }
  437. }))
  438. defer upstream.Close()
  439. handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
  440. Enabled: true,
  441. ClientID: "linuxdo-client",
  442. ClientSecret: "linuxdo-secret",
  443. AuthorizeURL: upstream.URL + "/authorize",
  444. TokenURL: upstream.URL + "/token",
  445. UserInfoURL: upstream.URL + "/userinfo",
  446. Scopes: "read",
  447. RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
  448. FrontendRedirectURL: "/auth/linuxdo/callback",
  449. TokenAuthMethod: "client_secret_post",
  450. UsePKCE: true,
  451. })
  452. t.Cleanup(func() { _ = client.Close() })
  453. ctx := context.Background()
  454. existingUser, err := client.User.Create().
  455. SetEmail(" Legacy@Example.com ").
  456. SetUsername("legacy-user").
  457. SetPasswordHash("hash").
  458. SetRole(service.RoleUser).
  459. SetStatus(service.StatusActive).
  460. Save(ctx)
  461. require.NoError(t, err)
  462. recorder := httptest.NewRecorder()
  463. c, _ := gin.CreateTestContext(recorder)
  464. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-compat&state=state-compat", nil)
  465. req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-compat"))
  466. req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
  467. req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-compat"))
  468. req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
  469. req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-compat"))
  470. c.Request = req
  471. handler.LinuxDoOAuthCallback(c)
  472. require.Equal(t, http.StatusFound, recorder.Code)
  473. require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
  474. sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
  475. require.NotNil(t, sessionCookie)
  476. session, err := client.PendingAuthSession.Query().
  477. Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
  478. Only(ctx)
  479. require.NoError(t, err)
  480. require.Equal(t, oauthIntentLogin, session.Intent)
  481. require.NotNil(t, session.TargetUserID)
  482. require.Equal(t, existingUser.ID, *session.TargetUserID)
  483. require.Equal(t, strings.TrimSpace(existingUser.Email), session.ResolvedEmail)
  484. require.Equal(t, "legacy@example.com", session.UpstreamIdentityClaims["compat_email"])
  485. completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
  486. require.True(t, ok)
  487. require.Equal(t, "/dashboard", completion["redirect"])
  488. require.Equal(t, oauthPendingChoiceStep, completion["step"])
  489. require.Equal(t, strings.TrimSpace(existingUser.Email), completion["email"])
  490. require.Equal(t, strings.TrimSpace(existingUser.Email), completion["existing_account_email"])
  491. require.Equal(t, true, completion["existing_account_bindable"])
  492. require.Equal(t, "compat_email_match", completion["choice_reason"])
  493. _, hasAccessToken := completion["access_token"]
  494. require.False(t, hasAccessToken)
  495. }
  496. func TestLinuxDoOAuthCallbackCreatesChoicePendingSessionWhenSignupRequiresInvite(t *testing.T) {
  497. upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  498. switch r.URL.Path {
  499. case "/token":
  500. w.Header().Set("Content-Type", "application/json")
  501. _, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
  502. case "/userinfo":
  503. w.Header().Set("Content-Type", "application/json")
  504. _, _ = w.Write([]byte(`{"id":"654","username":"linuxdo_invite","name":"Need Invite","avatar_url":"https://cdn.example/invite.png"}`))
  505. default:
  506. http.NotFound(w, r)
  507. }
  508. }))
  509. defer upstream.Close()
  510. handler, client := newLinuxDoOAuthHandlerAndClient(t, true, config.LinuxDoConnectConfig{
  511. Enabled: true,
  512. ClientID: "linuxdo-client",
  513. ClientSecret: "linuxdo-secret",
  514. AuthorizeURL: upstream.URL + "/authorize",
  515. TokenURL: upstream.URL + "/token",
  516. UserInfoURL: upstream.URL + "/userinfo",
  517. Scopes: "read",
  518. RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
  519. FrontendRedirectURL: "/auth/linuxdo/callback",
  520. TokenAuthMethod: "client_secret_post",
  521. UsePKCE: true,
  522. })
  523. t.Cleanup(func() { _ = client.Close() })
  524. recorder := httptest.NewRecorder()
  525. c, _ := gin.CreateTestContext(recorder)
  526. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-456&state=state-456", nil)
  527. req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-456"))
  528. req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
  529. req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-456"))
  530. req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
  531. req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-456"))
  532. c.Request = req
  533. handler.LinuxDoOAuthCallback(c)
  534. require.Equal(t, http.StatusFound, recorder.Code)
  535. require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
  536. sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
  537. require.NotNil(t, sessionCookie)
  538. ctx := context.Background()
  539. session, err := client.PendingAuthSession.Query().
  540. Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
  541. Only(ctx)
  542. require.NoError(t, err)
  543. require.Equal(t, oauthIntentLogin, session.Intent)
  544. require.Nil(t, session.TargetUserID)
  545. completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
  546. require.True(t, ok)
  547. require.Equal(t, oauthPendingChoiceStep, completion["step"])
  548. require.Equal(t, "/dashboard", completion["redirect"])
  549. require.Equal(t, "third_party_signup", completion["choice_reason"])
  550. }
  551. func TestLinuxDoOAuthCallbackEmailVerificationCompletesWithBoundEmail(t *testing.T) {
  552. upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  553. switch r.URL.Path {
  554. case "/token":
  555. w.Header().Set("Content-Type", "application/json")
  556. _, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
  557. case "/userinfo":
  558. w.Header().Set("Content-Type", "application/json")
  559. _, _ = w.Write([]byte(`{"id":"email-verify-123","username":"linuxdo_email","name":"Email Verify","avatar_url":"https://cdn.example/email.png"}`))
  560. default:
  561. http.NotFound(w, r)
  562. }
  563. }))
  564. defer upstream.Close()
  565. handler, client := newLinuxDoOAuthHandlerAndClientWithEmailVerification(t, false, "fresh@example.com", "246810", config.LinuxDoConnectConfig{
  566. Enabled: true,
  567. ClientID: "linuxdo-client",
  568. ClientSecret: "linuxdo-secret",
  569. AuthorizeURL: upstream.URL + "/authorize",
  570. TokenURL: upstream.URL + "/token",
  571. UserInfoURL: upstream.URL + "/userinfo",
  572. Scopes: "read",
  573. RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
  574. FrontendRedirectURL: "/auth/linuxdo/callback",
  575. TokenAuthMethod: "client_secret_post",
  576. UsePKCE: true,
  577. })
  578. t.Cleanup(func() { _ = client.Close() })
  579. recorder := httptest.NewRecorder()
  580. c, _ := gin.CreateTestContext(recorder)
  581. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-email&state=state-email", nil)
  582. req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-email"))
  583. req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
  584. req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-email"))
  585. req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
  586. req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-email"))
  587. c.Request = req
  588. handler.LinuxDoOAuthCallback(c)
  589. require.Equal(t, http.StatusFound, recorder.Code)
  590. require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
  591. sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
  592. require.NotNil(t, sessionCookie)
  593. ctx := context.Background()
  594. session, err := client.PendingAuthSession.Query().
  595. Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
  596. Only(ctx)
  597. require.NoError(t, err)
  598. require.Equal(t, oauthIntentLogin, session.Intent)
  599. require.Nil(t, session.TargetUserID)
  600. require.Empty(t, session.ResolvedEmail)
  601. require.Equal(t, "linuxdo-email-verify-123@linuxdo-connect.invalid", session.UpstreamIdentityClaims["email"])
  602. completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
  603. require.True(t, ok)
  604. require.Equal(t, "create_account_required", completion["step"])
  605. require.Equal(t, true, completion["email_binding_required"])
  606. require.Equal(t, true, completion["force_email_on_signup"])
  607. require.Equal(t, "email_verification_required", completion["choice_reason"])
  608. require.NotContains(t, completion, "email")
  609. require.NotContains(t, completion, "resolved_email")
  610. createRecorder := httptest.NewRecorder()
  611. createCtx, _ := gin.CreateTestContext(createRecorder)
  612. body := bytes.NewBufferString(`{"email":"fresh@example.com","verify_code":"246810","password":"secret-123","adopt_display_name":false,"adopt_avatar":false}`)
  613. createReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/create-account", body)
  614. createReq.Header.Set("Content-Type", "application/json")
  615. createReq.AddCookie(sessionCookie)
  616. createReq.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("browser-email")})
  617. createCtx.Request = createReq
  618. handler.CreatePendingOAuthAccount(createCtx)
  619. require.Equal(t, http.StatusOK, createRecorder.Code)
  620. responseData := decodeJSONBody(t, createRecorder)
  621. require.NotEmpty(t, responseData["access_token"])
  622. userEntity, err := client.User.Query().
  623. Where(dbuser.EmailEQ("fresh@example.com")).
  624. Only(ctx)
  625. require.NoError(t, err)
  626. require.Equal(t, "linuxdo", userEntity.SignupSource)
  627. identity, err := client.AuthIdentity.Query().
  628. Where(
  629. authidentity.ProviderTypeEQ("linuxdo"),
  630. authidentity.ProviderKeyEQ("linuxdo"),
  631. authidentity.ProviderSubjectEQ("email-verify-123"),
  632. ).
  633. Only(ctx)
  634. require.NoError(t, err)
  635. require.Equal(t, userEntity.ID, identity.UserID)
  636. storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
  637. require.NoError(t, err)
  638. require.NotNil(t, storedSession.ConsumedAt)
  639. }
  640. func TestLinuxDoOAuthCallbackDirectlyLogsInNewUserWhenEmailVerificationDisabled(t *testing.T) {
  641. upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  642. switch r.URL.Path {
  643. case "/token":
  644. w.Header().Set("Content-Type", "application/json")
  645. _, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
  646. case "/userinfo":
  647. w.Header().Set("Content-Type", "application/json")
  648. _, _ = w.Write([]byte(`{"id":"direct-123","username":"linuxdo_direct","name":"Direct Login","avatar_url":"https://cdn.example/direct.png"}`))
  649. default:
  650. http.NotFound(w, r)
  651. }
  652. }))
  653. defer upstream.Close()
  654. handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
  655. Enabled: true,
  656. ClientID: "linuxdo-client",
  657. ClientSecret: "linuxdo-secret",
  658. AuthorizeURL: upstream.URL + "/authorize",
  659. TokenURL: upstream.URL + "/token",
  660. UserInfoURL: upstream.URL + "/userinfo",
  661. Scopes: "read",
  662. RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
  663. FrontendRedirectURL: "/auth/linuxdo/callback",
  664. TokenAuthMethod: "client_secret_post",
  665. UsePKCE: true,
  666. })
  667. t.Cleanup(func() { _ = client.Close() })
  668. recorder := httptest.NewRecorder()
  669. c, _ := gin.CreateTestContext(recorder)
  670. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-direct&state=state-direct", nil)
  671. req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-direct"))
  672. req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
  673. req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-direct"))
  674. req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
  675. req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-direct"))
  676. c.Request = req
  677. handler.LinuxDoOAuthCallback(c)
  678. require.Equal(t, http.StatusFound, recorder.Code)
  679. location := recorder.Header().Get("Location")
  680. require.Contains(t, location, "/auth/linuxdo/callback#")
  681. require.Contains(t, location, "access_token=")
  682. require.Contains(t, location, "refresh_token=")
  683. fragmentValues := parseOAuthRedirectFragment(t, location)
  684. require.Equal(t, "/dashboard", fragmentValues.Get("redirect"))
  685. requireCookieCleared(t, recorder, oauthPendingSessionCookieName)
  686. requireCookieCleared(t, recorder, oauthPendingBrowserCookieName)
  687. ctx := context.Background()
  688. userEntity, err := client.User.Query().
  689. Where(dbuser.EmailEQ("linuxdo-direct-123@linuxdo-connect.invalid")).
  690. Only(ctx)
  691. require.NoError(t, err)
  692. require.Equal(t, "linuxdo_direct", userEntity.Username)
  693. require.Equal(t, "linuxdo", userEntity.SignupSource)
  694. identity, err := client.AuthIdentity.Query().
  695. Where(
  696. authidentity.ProviderTypeEQ("linuxdo"),
  697. authidentity.ProviderKeyEQ("linuxdo"),
  698. authidentity.ProviderSubjectEQ("direct-123"),
  699. ).
  700. Only(ctx)
  701. require.NoError(t, err)
  702. require.Equal(t, userEntity.ID, identity.UserID)
  703. require.Equal(t, "https://cdn.example/direct.png", identity.Metadata["suggested_avatar_url"])
  704. sessionCount, err := client.PendingAuthSession.Query().Count(ctx)
  705. require.NoError(t, err)
  706. require.Zero(t, sessionCount)
  707. }
  708. func TestLinuxDoOAuthCallbackCreatesBindPendingSessionForCurrentUser(t *testing.T) {
  709. upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  710. switch r.URL.Path {
  711. case "/token":
  712. w.Header().Set("Content-Type", "application/json")
  713. _, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
  714. case "/userinfo":
  715. w.Header().Set("Content-Type", "application/json")
  716. _, _ = w.Write([]byte(`{"id":"999","username":"bind_user","name":"Bind Display","avatar_url":"https://cdn.example/bind.png"}`))
  717. default:
  718. http.NotFound(w, r)
  719. }
  720. }))
  721. defer upstream.Close()
  722. handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
  723. Enabled: true,
  724. ClientID: "linuxdo-client",
  725. ClientSecret: "linuxdo-secret",
  726. AuthorizeURL: upstream.URL + "/authorize",
  727. TokenURL: upstream.URL + "/token",
  728. UserInfoURL: upstream.URL + "/userinfo",
  729. Scopes: "read",
  730. RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
  731. FrontendRedirectURL: "/auth/linuxdo/callback",
  732. TokenAuthMethod: "client_secret_post",
  733. UsePKCE: true,
  734. })
  735. t.Cleanup(func() { _ = client.Close() })
  736. ctx := context.Background()
  737. currentUser, err := client.User.Create().
  738. SetEmail("current@example.com").
  739. SetUsername("current-user").
  740. SetPasswordHash("hash").
  741. SetRole(service.RoleUser).
  742. SetStatus(service.StatusActive).
  743. Save(ctx)
  744. require.NoError(t, err)
  745. recorder := httptest.NewRecorder()
  746. c, _ := gin.CreateTestContext(recorder)
  747. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-bind&state=state-bind", nil)
  748. req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-bind"))
  749. req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/settings/connections"))
  750. req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-bind"))
  751. req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentBindCurrentUser))
  752. req.AddCookie(encodedCookie(linuxDoOAuthBindUserCookieName, buildEncodedOAuthBindUserCookie(t, currentUser.ID, "test-secret")))
  753. req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-bind"))
  754. c.Request = req
  755. handler.LinuxDoOAuthCallback(c)
  756. require.Equal(t, http.StatusFound, recorder.Code)
  757. require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
  758. sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
  759. require.NotNil(t, sessionCookie)
  760. session, err := client.PendingAuthSession.Query().
  761. Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
  762. Only(ctx)
  763. require.NoError(t, err)
  764. require.Equal(t, oauthIntentBindCurrentUser, session.Intent)
  765. require.NotNil(t, session.TargetUserID)
  766. require.Equal(t, currentUser.ID, *session.TargetUserID)
  767. require.Equal(t, linuxDoSyntheticEmail("999"), session.ResolvedEmail)
  768. completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
  769. require.True(t, ok)
  770. require.Equal(t, "/settings/connections", completion["redirect"])
  771. require.Empty(t, completion["access_token"])
  772. require.Equal(t, "Bind Display", session.UpstreamIdentityClaims["suggested_display_name"])
  773. userCount, err := client.User.Query().Count(ctx)
  774. require.NoError(t, err)
  775. require.Equal(t, 1, userCount)
  776. }
  777. func TestCompleteLinuxDoOAuthRegistrationAppliesPendingAdoptionDecision(t *testing.T) {
  778. handler, client := newOAuthPendingFlowTestHandler(t, false)
  779. ctx := context.Background()
  780. session, err := client.PendingAuthSession.Create().
  781. SetSessionToken("linuxdo-complete-session").
  782. SetIntent("login").
  783. SetProviderType("linuxdo").
  784. SetProviderKey("linuxdo").
  785. SetProviderSubject("linuxdo-subject-1").
  786. SetResolvedEmail("linuxdo-subject-1@linuxdo-connect.invalid").
  787. SetBrowserSessionKey("linuxdo-browser").
  788. SetUpstreamIdentityClaims(map[string]any{
  789. "username": "linuxdo_user",
  790. "suggested_display_name": "LinuxDo Display",
  791. "suggested_avatar_url": "https://cdn.example/linuxdo.png",
  792. }).
  793. SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
  794. Save(ctx)
  795. require.NoError(t, err)
  796. _, err = service.NewAuthPendingIdentityService(client).UpsertAdoptionDecision(ctx, service.PendingIdentityAdoptionDecisionInput{
  797. PendingAuthSessionID: session.ID,
  798. AdoptAvatar: true,
  799. })
  800. require.NoError(t, err)
  801. body := bytes.NewBufferString(`{"invitation_code":"invite-1","adopt_display_name":true}`)
  802. recorder := httptest.NewRecorder()
  803. c, _ := gin.CreateTestContext(recorder)
  804. req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/linuxdo/complete-registration", body)
  805. req.Header.Set("Content-Type", "application/json")
  806. req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
  807. req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("linuxdo-browser")})
  808. c.Request = req
  809. handler.CompleteLinuxDoOAuthRegistration(c)
  810. require.Equal(t, http.StatusOK, recorder.Code)
  811. responseData := decodeJSONBody(t, recorder)
  812. require.NotEmpty(t, responseData["access_token"])
  813. userEntity, err := client.User.Query().
  814. Where(dbuser.EmailEQ(session.ResolvedEmail)).
  815. Only(ctx)
  816. require.NoError(t, err)
  817. require.Equal(t, "LinuxDo Display", userEntity.Username)
  818. identity, err := client.AuthIdentity.Query().
  819. Where(
  820. authidentity.ProviderTypeEQ("linuxdo"),
  821. authidentity.ProviderKeyEQ("linuxdo"),
  822. authidentity.ProviderSubjectEQ("linuxdo-subject-1"),
  823. ).
  824. Only(ctx)
  825. require.NoError(t, err)
  826. require.Equal(t, userEntity.ID, identity.UserID)
  827. require.Equal(t, "LinuxDo Display", identity.Metadata["display_name"])
  828. require.Equal(t, "https://cdn.example/linuxdo.png", identity.Metadata["avatar_url"])
  829. decision, err := client.IdentityAdoptionDecision.Query().
  830. Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
  831. Only(ctx)
  832. require.NoError(t, err)
  833. require.NotNil(t, decision.IdentityID)
  834. require.Equal(t, identity.ID, *decision.IdentityID)
  835. require.True(t, decision.AdoptDisplayName)
  836. require.True(t, decision.AdoptAvatar)
  837. consumed, err := client.PendingAuthSession.Query().
  838. Where(pendingauthsession.IDEQ(session.ID)).
  839. Only(ctx)
  840. require.NoError(t, err)
  841. require.NotNil(t, consumed.ConsumedAt)
  842. }
  843. func TestCompleteLinuxDoOAuthRegistrationRejectsAdoptExistingUserSession(t *testing.T) {
  844. handler, client := newOAuthPendingFlowTestHandler(t, false)
  845. ctx := context.Background()
  846. existingUser, err := client.User.Create().
  847. SetEmail("owner@example.com").
  848. SetUsername("owner-user").
  849. SetPasswordHash("hash").
  850. SetRole(service.RoleUser).
  851. SetStatus(service.StatusActive).
  852. Save(ctx)
  853. require.NoError(t, err)
  854. session, err := client.PendingAuthSession.Create().
  855. SetSessionToken("linuxdo-complete-invalid-session").
  856. SetIntent("adopt_existing_user_by_email").
  857. SetProviderType("linuxdo").
  858. SetProviderKey("linuxdo").
  859. SetProviderSubject("linuxdo-invalid-subject-1").
  860. SetTargetUserID(existingUser.ID).
  861. SetResolvedEmail(existingUser.Email).
  862. SetBrowserSessionKey("linuxdo-invalid-browser").
  863. SetUpstreamIdentityClaims(map[string]any{
  864. "username": "linuxdo_user",
  865. }).
  866. SetLocalFlowState(map[string]any{
  867. oauthCompletionResponseKey: map[string]any{
  868. "step": "bind_login_required",
  869. },
  870. }).
  871. SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
  872. Save(ctx)
  873. require.NoError(t, err)
  874. body := bytes.NewBufferString(`{"invitation_code":"invite-1"}`)
  875. recorder := httptest.NewRecorder()
  876. c, _ := gin.CreateTestContext(recorder)
  877. req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/linuxdo/complete-registration", body)
  878. req.Header.Set("Content-Type", "application/json")
  879. req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
  880. req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("linuxdo-invalid-browser")})
  881. c.Request = req
  882. handler.CompleteLinuxDoOAuthRegistration(c)
  883. require.Equal(t, http.StatusBadRequest, recorder.Code)
  884. storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
  885. require.NoError(t, err)
  886. require.Nil(t, storedSession.ConsumedAt)
  887. }
  888. func TestCompleteLinuxDoOAuthRegistrationReturnsPendingSessionWhenChoiceStillRequired(t *testing.T) {
  889. handler, client := newOAuthPendingFlowTestHandler(t, false)
  890. ctx := context.Background()
  891. session, err := client.PendingAuthSession.Create().
  892. SetSessionToken("linuxdo-complete-choice-session").
  893. SetIntent("login").
  894. SetProviderType("linuxdo").
  895. SetProviderKey("linuxdo").
  896. SetProviderSubject("linuxdo-choice-subject-1").
  897. SetResolvedEmail("linuxdo-choice-subject-1@linuxdo-connect.invalid").
  898. SetBrowserSessionKey("linuxdo-choice-browser").
  899. SetUpstreamIdentityClaims(map[string]any{
  900. "username": "linuxdo_user",
  901. }).
  902. SetLocalFlowState(map[string]any{
  903. oauthCompletionResponseKey: map[string]any{
  904. "step": oauthPendingChoiceStep,
  905. "redirect": "/dashboard",
  906. "email": "fresh@example.com",
  907. "resolved_email": "fresh@example.com",
  908. "force_email_on_signup": true,
  909. },
  910. }).
  911. SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
  912. Save(ctx)
  913. require.NoError(t, err)
  914. body := bytes.NewBufferString(`{"invitation_code":"invite-1"}`)
  915. recorder := httptest.NewRecorder()
  916. c, _ := gin.CreateTestContext(recorder)
  917. req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/linuxdo/complete-registration", body)
  918. req.Header.Set("Content-Type", "application/json")
  919. req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
  920. req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("linuxdo-choice-browser")})
  921. c.Request = req
  922. handler.CompleteLinuxDoOAuthRegistration(c)
  923. require.Equal(t, http.StatusOK, recorder.Code)
  924. responseData := decodeJSONBody(t, recorder)
  925. require.Equal(t, "pending_session", responseData["auth_result"])
  926. require.Equal(t, oauthPendingChoiceStep, responseData["step"])
  927. require.Equal(t, true, responseData["force_email_on_signup"])
  928. require.Empty(t, responseData["access_token"])
  929. userCount, err := client.User.Query().Count(ctx)
  930. require.NoError(t, err)
  931. require.Zero(t, userCount)
  932. storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
  933. require.NoError(t, err)
  934. require.Nil(t, storedSession.ConsumedAt)
  935. }
  936. func TestCompleteLinuxDoOAuthRegistrationBindsIdentityWithoutAdoptionFlags(t *testing.T) {
  937. handler, client := newOAuthPendingFlowTestHandler(t, false)
  938. ctx := context.Background()
  939. session, err := client.PendingAuthSession.Create().
  940. SetSessionToken("linuxdo-complete-no-adoption-session").
  941. SetIntent("login").
  942. SetProviderType("linuxdo").
  943. SetProviderKey("linuxdo").
  944. SetProviderSubject("linuxdo-subject-no-adoption").
  945. SetResolvedEmail("linuxdo-subject-no-adoption@linuxdo-connect.invalid").
  946. SetBrowserSessionKey("linuxdo-browser-no-adoption").
  947. SetUpstreamIdentityClaims(map[string]any{
  948. "username": "linuxdo_user",
  949. "suggested_display_name": "LinuxDo Legacy",
  950. "suggested_avatar_url": "https://cdn.example/linuxdo-legacy.png",
  951. }).
  952. SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
  953. Save(ctx)
  954. require.NoError(t, err)
  955. body := bytes.NewBufferString(`{"invitation_code":"invite-1"}`)
  956. recorder := httptest.NewRecorder()
  957. c, _ := gin.CreateTestContext(recorder)
  958. req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/linuxdo/complete-registration", body)
  959. req.Header.Set("Content-Type", "application/json")
  960. req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
  961. req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("linuxdo-browser-no-adoption")})
  962. c.Request = req
  963. handler.CompleteLinuxDoOAuthRegistration(c)
  964. require.Equal(t, http.StatusOK, recorder.Code)
  965. responseData := decodeJSONBody(t, recorder)
  966. require.NotEmpty(t, responseData["access_token"])
  967. require.NotEmpty(t, responseData["refresh_token"])
  968. userEntity, err := client.User.Query().
  969. Where(dbuser.EmailEQ(session.ResolvedEmail)).
  970. Only(ctx)
  971. require.NoError(t, err)
  972. require.Equal(t, "linuxdo_user", userEntity.Username)
  973. identity, err := client.AuthIdentity.Query().
  974. Where(
  975. authidentity.ProviderTypeEQ("linuxdo"),
  976. authidentity.ProviderKeyEQ("linuxdo"),
  977. authidentity.ProviderSubjectEQ("linuxdo-subject-no-adoption"),
  978. ).
  979. Only(ctx)
  980. require.NoError(t, err)
  981. require.Equal(t, userEntity.ID, identity.UserID)
  982. decision, err := client.IdentityAdoptionDecision.Query().
  983. Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
  984. Only(ctx)
  985. require.NoError(t, err)
  986. require.NotNil(t, decision.IdentityID)
  987. require.Equal(t, identity.ID, *decision.IdentityID)
  988. require.False(t, decision.AdoptDisplayName)
  989. require.False(t, decision.AdoptAvatar)
  990. }
  991. func TestCompleteLinuxDoOAuthRegistrationRejectsIdentityOwnershipConflictBeforeUserCreation(t *testing.T) {
  992. handler, client := newOAuthPendingFlowTestHandler(t, false)
  993. ctx := context.Background()
  994. existingOwner, err := client.User.Create().
  995. SetEmail("owner@example.com").
  996. SetUsername("owner-user").
  997. SetPasswordHash("hash").
  998. SetRole(service.RoleUser).
  999. SetStatus(service.StatusActive).
  1000. Save(ctx)
  1001. require.NoError(t, err)
  1002. _, err = client.AuthIdentity.Create().
  1003. SetUserID(existingOwner.ID).
  1004. SetProviderType("linuxdo").
  1005. SetProviderKey("linuxdo").
  1006. SetProviderSubject("linuxdo-conflict-subject").
  1007. Save(ctx)
  1008. require.NoError(t, err)
  1009. session, err := client.PendingAuthSession.Create().
  1010. SetSessionToken("linuxdo-complete-conflict-session").
  1011. SetIntent("login").
  1012. SetProviderType("linuxdo").
  1013. SetProviderKey("linuxdo").
  1014. SetProviderSubject("linuxdo-conflict-subject").
  1015. SetResolvedEmail("linuxdo-conflict-subject@linuxdo-connect.invalid").
  1016. SetBrowserSessionKey("linuxdo-conflict-browser").
  1017. SetUpstreamIdentityClaims(map[string]any{
  1018. "username": "linuxdo_user",
  1019. }).
  1020. SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
  1021. Save(ctx)
  1022. require.NoError(t, err)
  1023. body := bytes.NewBufferString(`{"invitation_code":"invite-1"}`)
  1024. recorder := httptest.NewRecorder()
  1025. c, _ := gin.CreateTestContext(recorder)
  1026. req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/linuxdo/complete-registration", body)
  1027. req.Header.Set("Content-Type", "application/json")
  1028. req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
  1029. req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("linuxdo-conflict-browser")})
  1030. c.Request = req
  1031. handler.CompleteLinuxDoOAuthRegistration(c)
  1032. require.Equal(t, http.StatusConflict, recorder.Code)
  1033. payload := decodeJSONBody(t, recorder)
  1034. require.Equal(t, "AUTH_IDENTITY_OWNERSHIP_CONFLICT", payload["reason"])
  1035. userCount, err := client.User.Query().
  1036. Where(dbuser.EmailEQ("linuxdo-conflict-subject@linuxdo-connect.invalid")).
  1037. Count(ctx)
  1038. require.NoError(t, err)
  1039. require.Zero(t, userCount)
  1040. storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
  1041. require.NoError(t, err)
  1042. require.Nil(t, storedSession.ConsumedAt)
  1043. }
  1044. func newLinuxDoOAuthTestHandler(t *testing.T, invitationEnabled bool, oauthCfg config.LinuxDoConnectConfig) *AuthHandler {
  1045. t.Helper()
  1046. handler, _ := newLinuxDoOAuthHandlerAndClient(t, invitationEnabled, oauthCfg)
  1047. return handler
  1048. }
  1049. func newLinuxDoOAuthHandlerAndClient(t *testing.T, invitationEnabled bool, oauthCfg config.LinuxDoConnectConfig) (*AuthHandler, *dbent.Client) {
  1050. t.Helper()
  1051. handler, client := newOAuthPendingFlowTestHandler(t, invitationEnabled)
  1052. configureLinuxDoOAuthTestHandler(handler, oauthCfg)
  1053. return handler, client
  1054. }
  1055. func newLinuxDoOAuthHandlerAndClientWithEmailVerification(
  1056. t *testing.T,
  1057. invitationEnabled bool,
  1058. email string,
  1059. code string,
  1060. oauthCfg config.LinuxDoConnectConfig,
  1061. ) (*AuthHandler, *dbent.Client) {
  1062. t.Helper()
  1063. handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, invitationEnabled, email, code)
  1064. configureLinuxDoOAuthTestHandler(handler, oauthCfg)
  1065. return handler, client
  1066. }
  1067. func configureLinuxDoOAuthTestHandler(handler *AuthHandler, oauthCfg config.LinuxDoConnectConfig) {
  1068. handler.settingSvc = nil
  1069. handler.cfg = &config.Config{
  1070. JWT: config.JWTConfig{
  1071. Secret: "test-secret",
  1072. ExpireHour: 1,
  1073. AccessTokenExpireMinutes: 60,
  1074. RefreshTokenExpireDays: 7,
  1075. },
  1076. LinuxDo: oauthCfg,
  1077. }
  1078. }