// 外部測試套件:見 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) } }) }