login_psp_test.go 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. package handlers
  2. import (
  3. "context"
  4. "encoding/base64"
  5. "errors"
  6. "log/slog"
  7. "net/http"
  8. "net/http/httptest"
  9. "net/url"
  10. "strings"
  11. "testing"
  12. "github.com/stretchr/testify/assert"
  13. "github.com/mk6i/open-oscar-server/config"
  14. "github.com/mk6i/open-oscar-server/wire"
  15. )
  16. func TestAuthHandler_LoginPSP_GET(t *testing.T) {
  17. handler := &AuthHandler{Logger: slog.Default()}
  18. req := httptest.NewRequest(http.MethodGet, "/_cqr/login/login.psp?devId=dev1&succUrl=http%3A%2F%2Flocalhost%3A8000%2F", nil)
  19. rr := httptest.NewRecorder()
  20. handler.LoginPSP(rr, req)
  21. assert.Equal(t, http.StatusOK, rr.Code)
  22. assert.Contains(t, rr.Header().Get("Content-Type"), "text/html")
  23. assert.Contains(t, rr.Body.String(), "AIM Sign In")
  24. assert.Contains(t, rr.Body.String(), `name="devId" value="dev1"`)
  25. }
  26. func TestAuthHandler_Logout(t *testing.T) {
  27. handler := &AuthHandler{Logger: slog.Default()}
  28. req := httptest.NewRequest(http.MethodGet, "/auth/logout?f=json&a=sometoken&devId=dev1&succUrl=http%3A%2F%2Flocalhost%3A8000%2F.client%2F", nil)
  29. rr := httptest.NewRecorder()
  30. handler.Logout(rr, req)
  31. assert.Equal(t, http.StatusFound, rr.Code)
  32. loc, err := url.Parse(rr.Header().Get("Location"))
  33. assert.NoError(t, err)
  34. assert.Equal(t, "/_cqr/login/login.psp", loc.Path)
  35. assert.Equal(t, "dev1", loc.Query().Get("devId"))
  36. assert.Equal(t, "http://localhost:8000/.client/", loc.Query().Get("succUrl"))
  37. // Nothing to clear: getToken spent the token cookie signing this client in.
  38. assert.Empty(t, rr.Result().Cookies())
  39. }
  40. func TestAuthHandler_LoginPSP_POST_Success(t *testing.T) {
  41. var got wire.FLAPSignonFrame
  42. handler := &AuthHandler{
  43. AuthService: &testAuthService{
  44. flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
  45. got = inFrame
  46. return successfulLoginBlock(), nil
  47. },
  48. },
  49. Logger: slog.Default(),
  50. }
  51. form := url.Values{}
  52. form.Set("loginId", "testuser")
  53. form.Set("password", "secret")
  54. form.Set("devId", "dev1")
  55. form.Set("succUrl", "http://localhost:8000/")
  56. req := httptest.NewRequest(http.MethodPost, "/_cqr/login/login.psp", strings.NewReader(form.Encode()))
  57. req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  58. rr := httptest.NewRecorder()
  59. handler.LoginPSP(rr, req)
  60. assert.Equal(t, http.StatusFound, rr.Code)
  61. assert.Equal(t, "http://localhost:8000/", rr.Header().Get("Location"))
  62. set := make(map[string]*http.Cookie)
  63. for _, c := range rr.Result().Cookies() {
  64. set[c.Name] = c
  65. }
  66. // The cookie carries the BOS token from the login response, unchanged.
  67. tokenCookie := set[bosTokenCookie]
  68. if assert.NotNil(t, tokenCookie) {
  69. assert.True(t, tokenCookie.HttpOnly)
  70. raw, err := base64.URLEncoding.DecodeString(tokenCookie.Value)
  71. assert.NoError(t, err)
  72. assert.Equal(t, loginBlockCookie, raw)
  73. // It outlives the redirect but little else.
  74. assert.Equal(t, int(bosTokenTTL.Seconds()), tokenCookie.MaxAge)
  75. }
  76. for _, name := range []string{"RSP_USER", "RSP_LOCAL", "localAuthUser"} {
  77. assert.NotContains(t, set, name)
  78. }
  79. // The devId names the client on the resulting session.
  80. clientID, ok := got.String(wire.LoginTLVTagsClientIdentity)
  81. assert.True(t, ok, "signon frame should carry a client identity")
  82. assert.Equal(t, "dev1", clientID)
  83. }
  84. func TestAuthHandler_LoginPSP_POST_ServiceErrors(t *testing.T) {
  85. tests := []struct {
  86. name string
  87. flapLogin func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error)
  88. }{
  89. {
  90. name: "LoginResponseHasNoCookie",
  91. flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
  92. return blockWithoutCookie(), nil
  93. },
  94. },
  95. {
  96. name: "AuthServiceUnreachable",
  97. flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
  98. return wire.TLVRestBlock{}, errors.New("boom")
  99. },
  100. },
  101. }
  102. for _, tt := range tests {
  103. t.Run(tt.name, func(t *testing.T) {
  104. handler := &AuthHandler{
  105. AuthService: &testAuthService{flapLogin: tt.flapLogin},
  106. Logger: slog.Default(),
  107. }
  108. form := url.Values{}
  109. form.Set("loginId", "testuser")
  110. form.Set("password", "secret")
  111. req := httptest.NewRequest(http.MethodPost, "/_cqr/login/login.psp", strings.NewReader(form.Encode()))
  112. req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  113. rr := httptest.NewRecorder()
  114. handler.LoginPSP(rr, req)
  115. // A broken auth service must not read as a mistyped password.
  116. assert.Equal(t, http.StatusInternalServerError, rr.Code)
  117. assert.NotContains(t, rr.Body.String(), "Invalid screen name or password")
  118. assert.Empty(t, rr.Result().Cookies())
  119. })
  120. }
  121. }
  122. func TestAuthHandler_LoginPSP_POST_InvalidCredentials(t *testing.T) {
  123. handler := &AuthHandler{
  124. AuthService: &testAuthService{
  125. flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
  126. return failedLoginBlock(), nil
  127. },
  128. },
  129. Logger: slog.Default(),
  130. }
  131. form := url.Values{}
  132. form.Set("loginId", "testuser")
  133. form.Set("password", "wrong")
  134. req := httptest.NewRequest(http.MethodPost, "/_cqr/login/login.psp", strings.NewReader(form.Encode()))
  135. req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  136. rr := httptest.NewRecorder()
  137. handler.LoginPSP(rr, req)
  138. assert.Equal(t, http.StatusOK, rr.Code)
  139. assert.Contains(t, rr.Body.String(), "Invalid screen name or password")
  140. }
  141. func TestDefaultLoginSuccURL(t *testing.T) {
  142. req := httptest.NewRequest(http.MethodGet, "http://ras.dev/_cqr/login/login.psp", nil)
  143. assert.Equal(t, "http://ras.dev/", defaultLoginSuccURL(req))
  144. // TLS terminated upstream, so the scheme only survives in the header.
  145. req.Header.Set("X-Forwarded-Proto", "https")
  146. assert.Equal(t, "https://ras.dev/", defaultLoginSuccURL(req))
  147. }
  148. func TestSafeLoginRedirectURL(t *testing.T) {
  149. req := httptest.NewRequest(http.MethodGet, "http://localhost/_cqr/login/login.psp", nil)
  150. assert.Equal(t, "http://localhost:8000/", safeLoginRedirectURL(req, "http://localhost:8000/"))
  151. assert.Equal(t, "http://localhost/", safeLoginRedirectURL(req, "http://evil.example/"))
  152. }