forked from alterminal/alterminal
236 lines
8.0 KiB
Go
236 lines
8.0 KiB
Go
// 外部測試套件:見 jwks_test.go 開頭說明。
|
||
package oidc_test
|
||
|
||
import (
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"strings"
|
||
"testing"
|
||
|
||
"alterminal/internal/oidc"
|
||
)
|
||
|
||
// client_id 或 redirect_uri 無法確認時不得重導(RFC 6749 §4.1.2.1),
|
||
// 直接回 400 錯誤頁。
|
||
func TestAuthorizeRejectsWithoutRedirect(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
h := oidc.AuthorizeHandler(e.db)
|
||
|
||
t.Run("未知 client_id", func(t *testing.T) {
|
||
q := authorizeQuery(e.app, "openid", "", "", "")
|
||
q = strings.Replace(q, url.QueryEscape(e.app.ClientID), url.QueryEscape("no-such-client"), 1)
|
||
rec := getAuthorize(h, q, e.sessionCookie())
|
||
if rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400", rec.Code)
|
||
}
|
||
if rec.Header().Get("Location") != "" {
|
||
t.Fatalf("不得重導: %s", rec.Header().Get("Location"))
|
||
}
|
||
})
|
||
|
||
t.Run("redirect_uri 未註冊", func(t *testing.T) {
|
||
q := authorizeQuery(e.app, "openid", "", "", "")
|
||
q = strings.Replace(q, url.QueryEscape(e.app.RedirectURIs[0]), url.QueryEscape("https://evil.example/cb"), 1)
|
||
rec := getAuthorize(h, q, e.sessionCookie())
|
||
if rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400", rec.Code)
|
||
}
|
||
if rec.Header().Get("Location") != "" {
|
||
t.Fatalf("不得重導: %s", rec.Header().Get("Location"))
|
||
}
|
||
})
|
||
}
|
||
|
||
// redirect_uri 可確認後,其餘請求錯誤以 302 重導回 RP,附 error 與
|
||
// 原 state(RFC 6749 §4.1.2.1)。
|
||
func TestAuthorizeRedirectsParameterErrors(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
h := oidc.AuthorizeHandler(e.db)
|
||
redirectURI := e.app.RedirectURIs[0]
|
||
|
||
tests := []struct {
|
||
name string
|
||
query string
|
||
wantErrCode string
|
||
wantRedirect string
|
||
}{
|
||
{
|
||
"response_type 不支援",
|
||
"response_type=token&client_id=" + e.app.ClientID + "&redirect_uri=" + url.QueryEscape(redirectURI) + "&scope=openid&state=xyz",
|
||
"unsupported_response_type",
|
||
redirectURI,
|
||
},
|
||
{
|
||
"scope 缺 openid",
|
||
authorizeQuery(e.app, "profile email", "xyz", "", ""),
|
||
"invalid_scope",
|
||
redirectURI,
|
||
},
|
||
{
|
||
"scope 超出註冊範圍",
|
||
authorizeQuery(e.app, "openid profile email offline_access unknown-scope", "xyz", "", ""),
|
||
"invalid_scope",
|
||
redirectURI,
|
||
},
|
||
{
|
||
"code_challenge_method=plain",
|
||
"response_type=code&client_id=" + e.app.ClientID + "&redirect_uri=" + url.QueryEscape(redirectURI) +
|
||
"&scope=openid&state=xyz&code_challenge=whatever&code_challenge_method=plain",
|
||
"invalid_request",
|
||
redirectURI,
|
||
},
|
||
{
|
||
"有 challenge 未指定 method",
|
||
"response_type=code&client_id=" + e.app.ClientID + "&redirect_uri=" + url.QueryEscape(redirectURI) +
|
||
"&scope=openid&state=xyz&code_challenge=whatever",
|
||
"invalid_request",
|
||
redirectURI,
|
||
},
|
||
{
|
||
"公開式 Client 未使用 PKCE",
|
||
authorizeQuery(e.pub, "openid", "xyz", "", ""),
|
||
"invalid_request",
|
||
e.pub.RedirectURIs[0],
|
||
},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
rec := getAuthorize(h, tt.query, e.sessionCookie())
|
||
if rec.Code != http.StatusFound {
|
||
t.Fatalf("status = %d, want 302, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
loc := redirectLocation(t, rec)
|
||
if base := loc.Scheme + "://" + loc.Host + loc.Path; base != tt.wantRedirect {
|
||
t.Fatalf("Location 基準 URL = %q, want %q", base, tt.wantRedirect)
|
||
}
|
||
if got := loc.Query().Get("error"); got != tt.wantErrCode {
|
||
t.Errorf("error = %q, want %q", got, tt.wantErrCode)
|
||
}
|
||
if got := loc.Query().Get("state"); got != "xyz" {
|
||
t.Errorf("state 應原樣回填, got %q", got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 未登入時導向 /login,next 攜帶完整授權請求(OIDC Core §3.1.2.2)。
|
||
func TestAuthorizeRedirectsToLoginWhenNotLoggedIn(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
q := authorizeQuery(e.app, "openid profile", "xyz", "n-1", "")
|
||
|
||
rec := getAuthorize(oidc.AuthorizeHandler(e.db), q) // 不帶 Session Cookie
|
||
if rec.Code != http.StatusSeeOther {
|
||
t.Fatalf("status = %d, want 303", rec.Code)
|
||
}
|
||
loc := redirectLocation(t, rec)
|
||
if loc.Path != "/login" {
|
||
t.Fatalf("應導向 /login, got %q", loc)
|
||
}
|
||
next, err := url.QueryUnescape(loc.Query().Get("next"))
|
||
if err != nil {
|
||
t.Fatalf("next 未編碼: %v", err)
|
||
}
|
||
if !strings.HasPrefix(next, "/authorize?") || !strings.Contains(next, "state=xyz") || !strings.Contains(next, "nonce=n-1") {
|
||
t.Fatalf("next 應為完整 /authorize URL: %q", next)
|
||
}
|
||
}
|
||
|
||
// 首次授權顯示同意頁;同意後記住,同範圍的後續請求靜默通過;範圍
|
||
// 擴大時再次詢問。
|
||
func TestAuthorizeConsentFlow(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
h := oidc.AuthorizeHandler(e.db)
|
||
|
||
t.Run("首次顯示同意頁", func(t *testing.T) {
|
||
rec := getAuthorize(h, authorizeQuery(e.app, "openid profile", "xyz", "", ""), e.sessionCookie())
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", rec.Code)
|
||
}
|
||
body := rec.Body.String()
|
||
for _, want := range []string{"授權存取", "機密式測試應用", "openid", "profile", `value="allow"`, `value="deny"`} {
|
||
if !strings.Contains(body, want) {
|
||
t.Errorf("同意頁缺少 %q", want)
|
||
}
|
||
}
|
||
// offline_access 未請求,不應出現於說明清單。
|
||
if strings.Count(body, "offline_access") != 0 {
|
||
t.Error("未請求的 scope 不應顯示")
|
||
}
|
||
})
|
||
|
||
t.Run("同意後發碼並記住", func(t *testing.T) {
|
||
loc, code := consentAllow(t, e, authorizeQuery(e.app, "openid profile", "xyz", "", ""))
|
||
if base := loc.Scheme + "://" + loc.Host + loc.Path; base != e.app.RedirectURIs[0] {
|
||
t.Fatalf("Location 基準 URL = %q, want %q", base, e.app.RedirectURIs[0])
|
||
}
|
||
if loc.Query().Get("state") != "xyz" {
|
||
t.Errorf("state 應原樣回填, got %q", loc.Query().Get("state"))
|
||
}
|
||
if len(code) < 20 {
|
||
t.Errorf("code 長度 %d 過短", len(code))
|
||
}
|
||
})
|
||
|
||
t.Run("同範圍再請求靜默通過", func(t *testing.T) {
|
||
rec := getAuthorize(h, authorizeQuery(e.app, "openid", "s2", "", ""), e.sessionCookie())
|
||
if rec.Code != http.StatusFound {
|
||
t.Fatalf("status = %d, want 302, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
loc := redirectLocation(t, rec)
|
||
if loc.Query().Get("code") == "" || loc.Query().Get("state") != "s2" {
|
||
t.Fatalf("應直接發碼: %s", loc)
|
||
}
|
||
})
|
||
|
||
t.Run("範圍擴大再次詢問", func(t *testing.T) {
|
||
rec := getAuthorize(h, authorizeQuery(e.app, "openid email", "s3", "", ""), e.sessionCookie())
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200(應再顯示同意頁), body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
if !strings.Contains(rec.Body.String(), "email") {
|
||
t.Fatal("同意頁應顯示新請求的 scope")
|
||
}
|
||
})
|
||
|
||
t.Run("拒絕授權回 access_denied", func(t *testing.T) {
|
||
q := authorizeQuery(e.pub, "openid", "xyz", "", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
|
||
rec := getAuthorize(h, q, e.sessionCookie())
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("公開式首授權也應先顯示同意頁, status = %d", rec.Code)
|
||
}
|
||
rec = postAuthorize(h, q, "deny", e.sessionCookie(), csrfCookieOf(t, rec))
|
||
if rec.Code != http.StatusFound {
|
||
t.Fatalf("status = %d, want 302", rec.Code)
|
||
}
|
||
loc := redirectLocation(t, rec)
|
||
if loc.Query().Get("error") != "access_denied" || loc.Query().Get("state") != "xyz" {
|
||
t.Fatalf("應回 access_denied 與原 state: %s", loc)
|
||
}
|
||
// 拒絕不應記錄同意:再次請求仍顯示同意頁。
|
||
rec = getAuthorize(h, q, e.sessionCookie())
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("拒絕後不應記住, status = %d, want 200", rec.Code)
|
||
}
|
||
})
|
||
|
||
t.Run("CSRF 不符回 403", func(t *testing.T) {
|
||
q := authorizeQuery(e.app, "openid email", "", "", "") // email 尚未同意
|
||
rec := getAuthorize(h, q, e.sessionCookie())
|
||
csrf := csrfCookieOf(t, rec)
|
||
// Cookie 保持正確值,但表單送出不符的 token。
|
||
form, _ := url.ParseQuery(q)
|
||
form.Set("decision", "allow")
|
||
form.Set("csrf_token", "wrong-token")
|
||
req := httptest.NewRequest(http.MethodPost, "/authorize", strings.NewReader(form.Encode()))
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
req.AddCookie(e.sessionCookie())
|
||
req.AddCookie(csrf)
|
||
rec = httptest.NewRecorder()
|
||
h(rec, req)
|
||
if rec.Code != http.StatusForbidden {
|
||
t.Fatalf("status = %d, want 403", rec.Code)
|
||
}
|
||
})
|
||
}
|