704 lines
28 KiB
Go
704 lines
28 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
|
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
|
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
|
|
"github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
|
|
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestSanitizeFrontendRedirectPath(t *testing.T) {
|
|
require.Equal(t, "/dashboard", sanitizeFrontendRedirectPath("/dashboard"))
|
|
require.Equal(t, "/dashboard", sanitizeFrontendRedirectPath(" /dashboard "))
|
|
require.Equal(t, "", sanitizeFrontendRedirectPath("dashboard"))
|
|
require.Equal(t, "", sanitizeFrontendRedirectPath("//evil.com"))
|
|
require.Equal(t, "", sanitizeFrontendRedirectPath("https://evil.com"))
|
|
require.Equal(t, "", sanitizeFrontendRedirectPath("/\nfoo"))
|
|
|
|
long := "/" + strings.Repeat("a", linuxDoOAuthMaxRedirectLen)
|
|
require.Equal(t, "", sanitizeFrontendRedirectPath(long))
|
|
}
|
|
|
|
func TestBuildBearerAuthorization(t *testing.T) {
|
|
auth, err := buildBearerAuthorization("", "token123")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "Bearer token123", auth)
|
|
|
|
auth, err = buildBearerAuthorization("bearer", "token123")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "Bearer token123", auth)
|
|
|
|
_, err = buildBearerAuthorization("MAC", "token123")
|
|
require.Error(t, err)
|
|
|
|
_, err = buildBearerAuthorization("Bearer", "token 123")
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestLinuxDoParseUserInfoParsesIDAndUsername(t *testing.T) {
|
|
cfg := config.LinuxDoConnectConfig{
|
|
UserInfoURL: "https://connect.linux.do/api/user",
|
|
}
|
|
|
|
email, username, subject, displayName, avatarURL, err := linuxDoParseUserInfo(`{"id":123,"username":"alice","name":"Alice","avatar_url":"https://cdn.example/avatar.png"}`, cfg)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "123", subject)
|
|
require.Equal(t, "alice", username)
|
|
require.Equal(t, "linuxdo-123@linuxdo-connect.invalid", email)
|
|
require.Equal(t, "Alice", displayName)
|
|
require.Equal(t, "https://cdn.example/avatar.png", avatarURL)
|
|
}
|
|
|
|
func TestLinuxDoParseUserInfoDefaultsUsername(t *testing.T) {
|
|
cfg := config.LinuxDoConnectConfig{
|
|
UserInfoURL: "https://connect.linux.do/api/user",
|
|
}
|
|
|
|
email, username, subject, displayName, avatarURL, err := linuxDoParseUserInfo(`{"id":"123"}`, cfg)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "123", subject)
|
|
require.Equal(t, "linuxdo_123", username)
|
|
require.Equal(t, "linuxdo-123@linuxdo-connect.invalid", email)
|
|
require.Equal(t, "linuxdo_123", displayName)
|
|
require.Equal(t, "", avatarURL)
|
|
}
|
|
|
|
func TestLinuxDoParseUserInfoRejectsUnsafeSubject(t *testing.T) {
|
|
cfg := config.LinuxDoConnectConfig{
|
|
UserInfoURL: "https://connect.linux.do/api/user",
|
|
}
|
|
|
|
_, _, _, _, _, err := linuxDoParseUserInfo(`{"id":"123@456"}`, cfg)
|
|
require.Error(t, err)
|
|
|
|
tooLong := strings.Repeat("a", linuxDoOAuthMaxSubjectLen+1)
|
|
_, _, _, _, _, err = linuxDoParseUserInfo(`{"id":"`+tooLong+`"}`, cfg)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestParseOAuthProviderErrorJSON(t *testing.T) {
|
|
code, desc := parseOAuthProviderError(`{"error":"invalid_client","error_description":"bad secret"}`)
|
|
require.Equal(t, "invalid_client", code)
|
|
require.Equal(t, "bad secret", desc)
|
|
}
|
|
|
|
func TestParseOAuthProviderErrorForm(t *testing.T) {
|
|
code, desc := parseOAuthProviderError("error=invalid_request&error_description=Missing+code_verifier")
|
|
require.Equal(t, "invalid_request", code)
|
|
require.Equal(t, "Missing code_verifier", desc)
|
|
}
|
|
|
|
func TestParseLinuxDoTokenResponseJSON(t *testing.T) {
|
|
token, ok := parseLinuxDoTokenResponse(`{"access_token":"t1","token_type":"Bearer","expires_in":3600,"scope":"user"}`)
|
|
require.True(t, ok)
|
|
require.Equal(t, "t1", token.AccessToken)
|
|
require.Equal(t, "Bearer", token.TokenType)
|
|
require.Equal(t, int64(3600), token.ExpiresIn)
|
|
require.Equal(t, "user", token.Scope)
|
|
}
|
|
|
|
func TestParseLinuxDoTokenResponseForm(t *testing.T) {
|
|
token, ok := parseLinuxDoTokenResponse("access_token=t2&token_type=bearer&expires_in=60")
|
|
require.True(t, ok)
|
|
require.Equal(t, "t2", token.AccessToken)
|
|
require.Equal(t, "bearer", token.TokenType)
|
|
require.Equal(t, int64(60), token.ExpiresIn)
|
|
}
|
|
|
|
func TestSingleLineStripsWhitespace(t *testing.T) {
|
|
require.Equal(t, "hello world", singleLine("hello\r\nworld"))
|
|
require.Equal(t, "", singleLine("\n\t\r"))
|
|
}
|
|
|
|
func TestLinuxDoOAuthBindStartRedirectsAndSetsBindCookies(t *testing.T) {
|
|
handler := newLinuxDoOAuthTestHandler(t, false, config.LinuxDoConnectConfig{
|
|
Enabled: true,
|
|
ClientID: "linuxdo-client",
|
|
ClientSecret: "linuxdo-secret",
|
|
AuthorizeURL: "https://connect.linux.do/oauth/authorize",
|
|
TokenURL: "https://connect.linux.do/oauth/token",
|
|
UserInfoURL: "https://connect.linux.do/api/user",
|
|
Scopes: "read",
|
|
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
|
|
FrontendRedirectURL: "/auth/linuxdo/callback",
|
|
TokenAuthMethod: "client_secret_post",
|
|
UsePKCE: true,
|
|
})
|
|
|
|
recorder := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/bind/start?intent=bind_current_user&redirect=/settings/connections", nil)
|
|
c.Request = req
|
|
c.Set(string(servermiddleware.ContextKeyUser), servermiddleware.AuthSubject{UserID: 42})
|
|
|
|
handler.LinuxDoOAuthStart(c)
|
|
|
|
require.Equal(t, http.StatusFound, recorder.Code)
|
|
location := recorder.Header().Get("Location")
|
|
require.Contains(t, location, "connect.linux.do/oauth/authorize")
|
|
require.Contains(t, location, "client_id=linuxdo-client")
|
|
require.Contains(t, location, "code_challenge=")
|
|
|
|
cookies := recorder.Result().Cookies()
|
|
require.NotNil(t, findCookie(cookies, linuxDoOAuthStateCookieName))
|
|
require.NotNil(t, findCookie(cookies, linuxDoOAuthRedirectCookie))
|
|
require.NotNil(t, findCookie(cookies, linuxDoOAuthVerifierCookie))
|
|
require.NotNil(t, findCookie(cookies, oauthPendingBrowserCookieName))
|
|
|
|
intentCookie := findCookie(cookies, linuxDoOAuthIntentCookieName)
|
|
require.NotNil(t, intentCookie)
|
|
require.Equal(t, oauthIntentBindCurrentUser, decodeCookieValueForTest(t, intentCookie.Value))
|
|
|
|
bindCookie := findCookie(cookies, linuxDoOAuthBindUserCookieName)
|
|
require.NotNil(t, bindCookie)
|
|
userID, err := parseOAuthBindUserCookieValue(decodeCookieValueForTest(t, bindCookie.Value), "test-secret")
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(42), userID)
|
|
}
|
|
|
|
func TestLinuxDoOAuthBindStartAcceptsAccessTokenCookie(t *testing.T) {
|
|
handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
|
|
Enabled: true,
|
|
ClientID: "linuxdo-client",
|
|
ClientSecret: "linuxdo-secret",
|
|
AuthorizeURL: "https://connect.linux.do/oauth/authorize",
|
|
TokenURL: "https://connect.linux.do/oauth/token",
|
|
UserInfoURL: "https://connect.linux.do/api/user",
|
|
Scopes: "read",
|
|
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
|
|
FrontendRedirectURL: "/auth/linuxdo/callback",
|
|
TokenAuthMethod: "client_secret_post",
|
|
UsePKCE: true,
|
|
})
|
|
t.Cleanup(func() { _ = client.Close() })
|
|
|
|
user, err := client.User.Create().
|
|
SetEmail("bind-cookie@example.com").
|
|
SetUsername("bind-cookie-user").
|
|
SetPasswordHash("hash").
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(context.Background())
|
|
require.NoError(t, err)
|
|
|
|
token, err := handler.authService.GenerateToken(&service.User{
|
|
ID: user.ID,
|
|
Email: user.Email,
|
|
Username: user.Username,
|
|
PasswordHash: user.PasswordHash,
|
|
Role: user.Role,
|
|
Status: user.Status,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/start?intent=bind_current_user&redirect=/settings/connections", nil)
|
|
req.AddCookie(&http.Cookie{Name: oauthBindAccessTokenCookieName, Value: token, Path: oauthBindAccessTokenCookiePath})
|
|
c.Request = req
|
|
|
|
handler.LinuxDoOAuthStart(c)
|
|
|
|
require.Equal(t, http.StatusFound, recorder.Code)
|
|
|
|
bindCookie := findCookie(recorder.Result().Cookies(), linuxDoOAuthBindUserCookieName)
|
|
require.NotNil(t, bindCookie)
|
|
userID, err := parseOAuthBindUserCookieValue(decodeCookieValueForTest(t, bindCookie.Value), "test-secret")
|
|
require.NoError(t, err)
|
|
require.Equal(t, user.ID, userID)
|
|
|
|
accessTokenCookie := findCookie(recorder.Result().Cookies(), oauthBindAccessTokenCookieName)
|
|
require.NotNil(t, accessTokenCookie)
|
|
require.Equal(t, -1, accessTokenCookie.MaxAge)
|
|
}
|
|
|
|
func TestPrepareOAuthBindAccessTokenCookieSetsHttpOnlyCookie(t *testing.T) {
|
|
handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{})
|
|
t.Cleanup(func() { _ = client.Close() })
|
|
|
|
recorder := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/bind-token", nil)
|
|
req.Header.Set("Authorization", "Bearer access-token-value")
|
|
c.Request = req
|
|
|
|
handler.PrepareOAuthBindAccessTokenCookie(c)
|
|
|
|
require.Equal(t, http.StatusNoContent, recorder.Code)
|
|
accessTokenCookie := findCookie(recorder.Result().Cookies(), oauthBindAccessTokenCookieName)
|
|
require.NotNil(t, accessTokenCookie)
|
|
require.Equal(t, oauthBindAccessTokenCookiePath, accessTokenCookie.Path)
|
|
require.Equal(t, linuxDoOAuthCookieMaxAgeSec, accessTokenCookie.MaxAge)
|
|
require.True(t, accessTokenCookie.HttpOnly)
|
|
require.Equal(t, url.QueryEscape("access-token-value"), accessTokenCookie.Value)
|
|
}
|
|
|
|
func TestLinuxDoOAuthCallbackCreatesLoginPendingSessionForExistingIdentityUser(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/token":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
|
|
case "/userinfo":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"id":"321","username":"linuxdo_user","name":"LinuxDo Display","avatar_url":"https://cdn.example/linuxdo.png"}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
|
|
Enabled: true,
|
|
ClientID: "linuxdo-client",
|
|
ClientSecret: "linuxdo-secret",
|
|
AuthorizeURL: upstream.URL + "/authorize",
|
|
TokenURL: upstream.URL + "/token",
|
|
UserInfoURL: upstream.URL + "/userinfo",
|
|
Scopes: "read",
|
|
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
|
|
FrontendRedirectURL: "/auth/linuxdo/callback",
|
|
TokenAuthMethod: "client_secret_post",
|
|
UsePKCE: true,
|
|
})
|
|
t.Cleanup(func() { _ = client.Close() })
|
|
|
|
ctx := context.Background()
|
|
existingUser, err := client.User.Create().
|
|
SetEmail(linuxDoSyntheticEmail("321")).
|
|
SetUsername("legacy-user").
|
|
SetPasswordHash("hash").
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
_, err = client.AuthIdentity.Create().
|
|
SetUserID(existingUser.ID).
|
|
SetProviderType("linuxdo").
|
|
SetProviderKey("linuxdo").
|
|
SetProviderSubject("321").
|
|
SetMetadata(map[string]any{"username": "legacy-user"}).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-123&state=state-123", nil)
|
|
req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-123"))
|
|
req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
|
|
req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-123"))
|
|
req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
|
|
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-123"))
|
|
c.Request = req
|
|
|
|
handler.LinuxDoOAuthCallback(c)
|
|
|
|
require.Equal(t, http.StatusFound, recorder.Code)
|
|
require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
|
|
|
|
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
|
|
require.NotNil(t, sessionCookie)
|
|
|
|
session, err := client.PendingAuthSession.Query().
|
|
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, oauthIntentLogin, session.Intent)
|
|
require.NotNil(t, session.TargetUserID)
|
|
require.Equal(t, existingUser.ID, *session.TargetUserID)
|
|
require.Equal(t, linuxDoSyntheticEmail("321"), session.ResolvedEmail)
|
|
require.Equal(t, "LinuxDo Display", session.UpstreamIdentityClaims["suggested_display_name"])
|
|
|
|
completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
|
require.True(t, ok)
|
|
require.Equal(t, "/dashboard", completion["redirect"])
|
|
require.NotEmpty(t, completion["access_token"])
|
|
require.Nil(t, completion["error"])
|
|
}
|
|
|
|
func TestLinuxDoOAuthCallbackCreatesBindPendingSessionForCompatEmailUser(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/token":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
|
|
case "/userinfo":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"id":"321","email":"legacy@example.com","username":"linuxdo_user","name":"LinuxDo Display","avatar_url":"https://cdn.example/linuxdo.png"}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
|
|
Enabled: true,
|
|
ClientID: "linuxdo-client",
|
|
ClientSecret: "linuxdo-secret",
|
|
AuthorizeURL: upstream.URL + "/authorize",
|
|
TokenURL: upstream.URL + "/token",
|
|
UserInfoURL: upstream.URL + "/userinfo",
|
|
Scopes: "read",
|
|
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
|
|
FrontendRedirectURL: "/auth/linuxdo/callback",
|
|
TokenAuthMethod: "client_secret_post",
|
|
UsePKCE: true,
|
|
})
|
|
t.Cleanup(func() { _ = client.Close() })
|
|
|
|
ctx := context.Background()
|
|
existingUser, err := client.User.Create().
|
|
SetEmail("legacy@example.com").
|
|
SetUsername("legacy-user").
|
|
SetPasswordHash("hash").
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-compat&state=state-compat", nil)
|
|
req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-compat"))
|
|
req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
|
|
req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-compat"))
|
|
req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
|
|
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-compat"))
|
|
c.Request = req
|
|
|
|
handler.LinuxDoOAuthCallback(c)
|
|
|
|
require.Equal(t, http.StatusFound, recorder.Code)
|
|
require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
|
|
|
|
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
|
|
require.NotNil(t, sessionCookie)
|
|
|
|
session, err := client.PendingAuthSession.Query().
|
|
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, oauthIntentLogin, session.Intent)
|
|
require.Nil(t, session.TargetUserID)
|
|
require.Equal(t, existingUser.Email, session.ResolvedEmail)
|
|
require.Equal(t, "legacy@example.com", session.UpstreamIdentityClaims["compat_email"])
|
|
|
|
completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
|
require.True(t, ok)
|
|
require.Equal(t, "/dashboard", completion["redirect"])
|
|
require.Equal(t, oauthPendingChoiceStep, completion["step"])
|
|
require.Equal(t, existingUser.Email, completion["email"])
|
|
require.Equal(t, existingUser.Email, completion["existing_account_email"])
|
|
require.Equal(t, true, completion["existing_account_bindable"])
|
|
require.Equal(t, "compat_email_match", completion["choice_reason"])
|
|
_, hasAccessToken := completion["access_token"]
|
|
require.False(t, hasAccessToken)
|
|
}
|
|
|
|
func TestLinuxDoOAuthCallbackCreatesChoicePendingSessionWhenSignupRequiresInvite(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/token":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
|
|
case "/userinfo":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"id":"654","username":"linuxdo_invite","name":"Need Invite","avatar_url":"https://cdn.example/invite.png"}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
handler, client := newLinuxDoOAuthHandlerAndClient(t, true, config.LinuxDoConnectConfig{
|
|
Enabled: true,
|
|
ClientID: "linuxdo-client",
|
|
ClientSecret: "linuxdo-secret",
|
|
AuthorizeURL: upstream.URL + "/authorize",
|
|
TokenURL: upstream.URL + "/token",
|
|
UserInfoURL: upstream.URL + "/userinfo",
|
|
Scopes: "read",
|
|
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
|
|
FrontendRedirectURL: "/auth/linuxdo/callback",
|
|
TokenAuthMethod: "client_secret_post",
|
|
UsePKCE: true,
|
|
})
|
|
t.Cleanup(func() { _ = client.Close() })
|
|
|
|
recorder := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-456&state=state-456", nil)
|
|
req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-456"))
|
|
req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
|
|
req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-456"))
|
|
req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
|
|
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-456"))
|
|
c.Request = req
|
|
|
|
handler.LinuxDoOAuthCallback(c)
|
|
|
|
require.Equal(t, http.StatusFound, recorder.Code)
|
|
require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
|
|
|
|
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
|
|
require.NotNil(t, sessionCookie)
|
|
|
|
ctx := context.Background()
|
|
session, err := client.PendingAuthSession.Query().
|
|
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, oauthIntentLogin, session.Intent)
|
|
require.Nil(t, session.TargetUserID)
|
|
|
|
completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
|
require.True(t, ok)
|
|
require.Equal(t, oauthPendingChoiceStep, completion["step"])
|
|
require.Equal(t, "/dashboard", completion["redirect"])
|
|
require.Equal(t, "third_party_signup", completion["choice_reason"])
|
|
}
|
|
|
|
func TestLinuxDoOAuthCallbackCreatesBindPendingSessionForCurrentUser(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/token":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
|
|
case "/userinfo":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"id":"999","username":"bind_user","name":"Bind Display","avatar_url":"https://cdn.example/bind.png"}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
|
|
Enabled: true,
|
|
ClientID: "linuxdo-client",
|
|
ClientSecret: "linuxdo-secret",
|
|
AuthorizeURL: upstream.URL + "/authorize",
|
|
TokenURL: upstream.URL + "/token",
|
|
UserInfoURL: upstream.URL + "/userinfo",
|
|
Scopes: "read",
|
|
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
|
|
FrontendRedirectURL: "/auth/linuxdo/callback",
|
|
TokenAuthMethod: "client_secret_post",
|
|
UsePKCE: true,
|
|
})
|
|
t.Cleanup(func() { _ = client.Close() })
|
|
|
|
ctx := context.Background()
|
|
currentUser, err := client.User.Create().
|
|
SetEmail("current@example.com").
|
|
SetUsername("current-user").
|
|
SetPasswordHash("hash").
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-bind&state=state-bind", nil)
|
|
req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-bind"))
|
|
req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/settings/connections"))
|
|
req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-bind"))
|
|
req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentBindCurrentUser))
|
|
req.AddCookie(encodedCookie(linuxDoOAuthBindUserCookieName, buildEncodedOAuthBindUserCookie(t, currentUser.ID, "test-secret")))
|
|
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-bind"))
|
|
c.Request = req
|
|
|
|
handler.LinuxDoOAuthCallback(c)
|
|
|
|
require.Equal(t, http.StatusFound, recorder.Code)
|
|
require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
|
|
|
|
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
|
|
require.NotNil(t, sessionCookie)
|
|
|
|
session, err := client.PendingAuthSession.Query().
|
|
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, oauthIntentBindCurrentUser, session.Intent)
|
|
require.NotNil(t, session.TargetUserID)
|
|
require.Equal(t, currentUser.ID, *session.TargetUserID)
|
|
require.Equal(t, linuxDoSyntheticEmail("999"), session.ResolvedEmail)
|
|
|
|
completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
|
require.True(t, ok)
|
|
require.Equal(t, "/settings/connections", completion["redirect"])
|
|
require.Empty(t, completion["access_token"])
|
|
require.Equal(t, "Bind Display", session.UpstreamIdentityClaims["suggested_display_name"])
|
|
|
|
userCount, err := client.User.Query().Count(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, userCount)
|
|
}
|
|
|
|
func TestCompleteLinuxDoOAuthRegistrationAppliesPendingAdoptionDecision(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
|
ctx := context.Background()
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("linuxdo-complete-session").
|
|
SetIntent("login").
|
|
SetProviderType("linuxdo").
|
|
SetProviderKey("linuxdo").
|
|
SetProviderSubject("linuxdo-subject-1").
|
|
SetResolvedEmail("linuxdo-subject-1@linuxdo-connect.invalid").
|
|
SetBrowserSessionKey("linuxdo-browser").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"username": "linuxdo_user",
|
|
"suggested_display_name": "LinuxDo Display",
|
|
"suggested_avatar_url": "https://cdn.example/linuxdo.png",
|
|
}).
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
_, err = service.NewAuthPendingIdentityService(client).UpsertAdoptionDecision(ctx, service.PendingIdentityAdoptionDecisionInput{
|
|
PendingAuthSessionID: session.ID,
|
|
AdoptAvatar: true,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"invitation_code":"invite-1","adopt_display_name":true}`)
|
|
recorder := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/linuxdo/complete-registration", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
|
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("linuxdo-browser")})
|
|
c.Request = req
|
|
|
|
handler.CompleteLinuxDoOAuthRegistration(c)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
responseData := decodeJSONBody(t, recorder)
|
|
require.NotEmpty(t, responseData["access_token"])
|
|
|
|
userEntity, err := client.User.Query().
|
|
Where(dbuser.EmailEQ(session.ResolvedEmail)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "LinuxDo Display", userEntity.Username)
|
|
|
|
identity, err := client.AuthIdentity.Query().
|
|
Where(
|
|
authidentity.ProviderTypeEQ("linuxdo"),
|
|
authidentity.ProviderKeyEQ("linuxdo"),
|
|
authidentity.ProviderSubjectEQ("linuxdo-subject-1"),
|
|
).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, userEntity.ID, identity.UserID)
|
|
require.Equal(t, "LinuxDo Display", identity.Metadata["display_name"])
|
|
require.Equal(t, "https://cdn.example/linuxdo.png", identity.Metadata["avatar_url"])
|
|
|
|
decision, err := client.IdentityAdoptionDecision.Query().
|
|
Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, decision.IdentityID)
|
|
require.Equal(t, identity.ID, *decision.IdentityID)
|
|
require.True(t, decision.AdoptDisplayName)
|
|
require.True(t, decision.AdoptAvatar)
|
|
|
|
consumed, err := client.PendingAuthSession.Query().
|
|
Where(pendingauthsession.IDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, consumed.ConsumedAt)
|
|
}
|
|
|
|
func TestCompleteLinuxDoOAuthRegistrationRejectsAdoptExistingUserSession(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
|
ctx := context.Background()
|
|
|
|
existingUser, err := client.User.Create().
|
|
SetEmail("owner@example.com").
|
|
SetUsername("owner-user").
|
|
SetPasswordHash("hash").
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("linuxdo-complete-invalid-session").
|
|
SetIntent("adopt_existing_user_by_email").
|
|
SetProviderType("linuxdo").
|
|
SetProviderKey("linuxdo").
|
|
SetProviderSubject("linuxdo-invalid-subject-1").
|
|
SetTargetUserID(existingUser.ID).
|
|
SetResolvedEmail(existingUser.Email).
|
|
SetBrowserSessionKey("linuxdo-invalid-browser").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"username": "linuxdo_user",
|
|
}).
|
|
SetLocalFlowState(map[string]any{
|
|
oauthCompletionResponseKey: map[string]any{
|
|
"step": "bind_login_required",
|
|
},
|
|
}).
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"invitation_code":"invite-1"}`)
|
|
recorder := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/linuxdo/complete-registration", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
|
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("linuxdo-invalid-browser")})
|
|
c.Request = req
|
|
|
|
handler.CompleteLinuxDoOAuthRegistration(c)
|
|
|
|
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
|
|
|
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
|
require.NoError(t, err)
|
|
require.Nil(t, storedSession.ConsumedAt)
|
|
}
|
|
|
|
func newLinuxDoOAuthTestHandler(t *testing.T, invitationEnabled bool, oauthCfg config.LinuxDoConnectConfig) *AuthHandler {
|
|
t.Helper()
|
|
handler, _ := newLinuxDoOAuthHandlerAndClient(t, invitationEnabled, oauthCfg)
|
|
return handler
|
|
}
|
|
|
|
func newLinuxDoOAuthHandlerAndClient(t *testing.T, invitationEnabled bool, oauthCfg config.LinuxDoConnectConfig) (*AuthHandler, *dbent.Client) {
|
|
t.Helper()
|
|
handler, client := newOAuthPendingFlowTestHandler(t, invitationEnabled)
|
|
handler.settingSvc = nil
|
|
handler.cfg = &config.Config{
|
|
JWT: config.JWTConfig{
|
|
Secret: "test-secret",
|
|
ExpireHour: 1,
|
|
AccessTokenExpireMinutes: 60,
|
|
RefreshTokenExpireDays: 7,
|
|
},
|
|
LinuxDo: oauthCfg,
|
|
}
|
|
return handler, client
|
|
}
|