first commit

This commit is contained in:
2026-10-03 10:44:29 +08:00
parent f373cb8d37
commit bcf3d3769c
58 changed files with 4313 additions and 487 deletions
+302
View File
@@ -0,0 +1,302 @@
package application
import (
"errors"
"fmt"
"net/url"
"sort"
"strings"
"time"
"gorm.io/gorm"
"alterminal/internal/auth"
)
// ClientType 為 OAuth 2.0 Client 類型(RFC 6749 §2.1):confidential 能
// 安全保管 client secret(後端網頁應用),public 不能(SPA、行動應用),
// 授權流程必須以 PKCE 彌補,不簽發 client secret。
type ClientType string
// 允許的類型值。
const (
ClientConfidential ClientType = "confidential"
ClientPublic ClientType = "public"
)
// valid 回傳類型是否為允許的值。
func (t ClientType) valid() bool {
return t == ClientConfidential || t == ClientPublic
}
// GrantType 為 OAuth 2.0 grant type。
type GrantType string
// 允許的 grant type 值。
const (
GrantAuthorizationCode GrantType = "authorization_code" // 授權碼流程(建議搭配 PKCE)
GrantRefreshToken GrantType = "refresh_token" // 以 Refresh Token 換發新權杖
GrantClientCredentials GrantType = "client_credentials" // 機器對機器,僅機密式 Client 可用
)
// valid 回傳 grant type 是否為允許的值。
func (g GrantType) valid() bool {
return g == GrantAuthorizationCode || g == GrantRefreshToken || g == GrantClientCredentials
}
// supportedScopes 為本服務支援的 scope(與 README「支援的 Scope」一致)。
var supportedScopes = map[string]bool{
"openid": true,
"profile": true,
"email": true,
"offline_access": true,
}
// defaultScope 為註冊時未指定 scope 的預設值。
const defaultScope = "openid profile email"
// ScopesSupported 回傳支援的 scope 清單(已排序),供 Discovery 端點的
// scopes_supported 發佈(與本套件的註冊驗證共用同一份清單)。
func ScopesSupported() []string {
out := make([]string, 0, len(supportedScopes))
for s := range supportedScopes {
out = append(out, s)
}
sort.Strings(out)
return out
}
// RedirectURIs 為已註冊的 redirect URI 清單(JSON 陣列儲存)。RFC 6749
// §3.1.2.3 要求端點比對時與註冊值完全相同(字串相等,不做正規化),
// 故以字串清單逐一比對。
type RedirectURIs []string
// Contains 回傳 uri 是否與任一註冊的 redirect URI 完全相同。
func (r RedirectURIs) Contains(uri string) bool {
for _, u := range r {
if u == uri {
return true
}
}
return false
}
// GrantTypes 為允許的 grant type 清單(JSON 陣列儲存)。
type GrantTypes []GrantType
// Contains 回傳 gt 是否為允許的 grant type。
func (g GrantTypes) Contains(gt GrantType) bool {
for _, x := range g {
if x == gt {
return true
}
}
return false
}
// Application 為接入 OIDC 的應用程式(Relying Party)註冊資料,對應
// applications 資料表。ClientID 由本服務產生、全域唯一;client secret
// 與使用者密碼採同一套 argon2id 雜湊儲存,明文只在建立/輪替當下回傳
// 一次;公開式 Client 不持有 secret。
type Application struct {
ID uint `gorm:"primaryKey"`
ClientID string `gorm:"uniqueIndex;size:22;not null"` // 16 bytes 亂數的 base64url(公開識別碼,128 bits 熵已足夠)
Name string `gorm:"size:255;not null"` // 顯示名稱(授權頁顯示「以 ○○ 登入」等)
Type ClientType `gorm:"size:16;not null"` // confidential 或 public
ClientSecretHash string `gorm:"size:255;not null"` // argon2id PHC 字串;public 為空字串
RedirectURIs RedirectURIs `gorm:"serializer:json;not null"` // 允許的 redirect URI(精確比對)
GrantTypes GrantTypes `gorm:"serializer:json;not null"` // 允許的 grant type
Scope string `gorm:"size:255;not null"` // 允許的 scope,空格分隔
CreatedAt time.Time
UpdatedAt time.Time
}
// IsPublic 回傳是否為公開式 Client(不持有 secret,授權流程必須使用 PKCE)。
func (a *Application) IsPublic() bool {
return a.Type == ClientPublic
}
// fill 套用註冊表單欄位並補上預設值(grantTypes 空時預設僅
// authorization_code;scope 空時預設「openid profile email」)後驗證,
// 供 NewApplication 與 Update 共用。驗證失敗時 a 可能已被部分修改,
// 呼叫方不應將其儲存。
func (a *Application) fill(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string) error {
a.Name = strings.TrimSpace(name)
a.Type = typ
a.RedirectURIs = append(RedirectURIs{}, redirectURIs...) // 保證非 nil,序列化為 [] 而非 null
a.GrantTypes = grantTypes
a.Scope = strings.TrimSpace(scope)
if len(a.GrantTypes) == 0 {
a.GrantTypes = GrantTypes{GrantAuthorizationCode}
}
if a.Scope == "" {
a.Scope = defaultScope
}
return a.Validate()
}
// NewApplication 建立新的應用程式註冊:先驗證內容,再產生全域唯一的
// client_id;機密式 Client 另產生 client secret,明文僅經回傳值交付一
// 次,呼叫方應立即提供給應用程式管理者,不得儲存明文。
func NewApplication(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string) (*Application, string, error) {
a := &Application{}
if err := a.fill(name, typ, redirectURIs, grantTypes, scope); err != nil {
return nil, "", err
}
id, err := auth.NewToken(16)
if err != nil {
return nil, "", fmt.Errorf("generate client id: %w", err)
}
a.ClientID = id
secret := ""
if !a.IsPublic() {
if secret, err = a.GenerateSecret(); err != nil {
return nil, "", err
}
}
return a, secret, nil
}
// Update 以新的註冊內容更新既有應用程式:client_id 為公開識別碼,已
// 嵌入各 RP 的設定,不可變更;client secret 亦不受影響(輪替另經
// GenerateSecret)。由機密式改為公開式時一併清除既有 secret 雜湊——
// 舊 secret 隨型別切換立即失效,日後改回機密式也不會復活,須重新輪替
// 取得新 secret。驗證失敗時 a 可能已被部分修改,呼叫方不應將其儲存。
func (a *Application) Update(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string) error {
if err := a.fill(name, typ, redirectURIs, grantTypes, scope); err != nil {
return err
}
if a.IsPublic() {
a.ClientSecretHash = ""
}
return nil
}
// Validate 檢查註冊內容:名稱與類型必填、grant type 受支援且組合合法
// (client_credentials 僅限機密式 Client(RFC 6749 §4.4.3)、
// refresh_token 須伴隨授權碼流程)、使用授權碼流程時至少註冊一個格式
// 正確的 redirect URI、scope 皆受支援且 offline_access 須有
// refresh_token grant。
func (a *Application) Validate() error {
if a.Name == "" {
return errors.New("應用程式名稱不可為空")
}
if !a.Type.valid() {
return fmt.Errorf("不支援的 client 類型 %q", a.Type)
}
if len(a.GrantTypes) == 0 {
return errors.New("至少須啟用一種 grant type")
}
for _, g := range a.GrantTypes {
if !g.valid() {
return fmt.Errorf("不支援的 grant type %q", g)
}
}
if a.GrantTypes.Contains(GrantClientCredentials) && a.IsPublic() {
return errors.New("公開式 Client 不可使用 client_credentials(RFC 6749 §4.4.3)")
}
if a.GrantTypes.Contains(GrantRefreshToken) && !a.GrantTypes.Contains(GrantAuthorizationCode) {
return errors.New("refresh_token 須伴隨 authorization_code 使用")
}
if a.GrantTypes.Contains(GrantAuthorizationCode) {
if len(a.RedirectURIs) == 0 {
return errors.New("使用授權碼流程須至少註冊一個 redirect URI")
}
for _, uri := range a.RedirectURIs {
if err := validateRedirectURI(uri); err != nil {
return fmt.Errorf("redirect URI %q:%w", uri, err)
}
}
}
if a.Scope == "" {
return errors.New("scope 不可為空")
}
for _, s := range strings.Fields(a.Scope) {
if !supportedScopes[s] {
return fmt.Errorf("不支援的 scope %q", s)
}
if s == "offline_access" && !a.GrantTypes.Contains(GrantRefreshToken) {
return errors.New("offline_access 須啟用 refresh_token grant")
}
}
return nil
}
// GenerateSecret 產生並雜湊新的 client secret(明文為 32 bytes 亂數的
// base64url,43 字元),回傳明文——僅此一次,資料庫只存雜湊。再次呼叫
// 即輪替,舊 secret 立即失效。公開式 Client 不持有 secret,回傳錯誤。
func (a *Application) GenerateSecret() (string, error) {
if a.IsPublic() {
return "", errors.New("公開式 Client 不持有 client secret")
}
secret, err := auth.NewToken(32)
if err != nil {
return "", fmt.Errorf("generate client secret: %w", err)
}
hash, err := auth.HashPassword(secret)
if err != nil {
return "", fmt.Errorf("hash client secret: %w", err)
}
a.ClientSecretHash = hash
return secret, nil
}
// CheckSecret 回傳 client secret 是否相符;公開式 Client 一律不相符,
// 雜湊格式無效時亦視為不相符。
func (a *Application) CheckSecret(secret string) bool {
if a.IsPublic() {
return false
}
ok, err := auth.VerifyPassword(secret, a.ClientSecretHash)
return err == nil && ok
}
// validateRedirectURI 檢查 redirect URI 格式:須為絕對 URI 且不含
// fragment 與 userinfo(RFC 6749 §3.1.2);http 僅允許 loopback(本機
// 開發,RFC 8252 §7.3),Web 應用一律使用 https;非 http(s) 的自訂
// scheme(如 com.example.app:/cb)供原生應用程式使用。
func validateRedirectURI(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("解析失敗:%w", err)
}
if !u.IsAbs() {
return errors.New("須為絕對 URI(含 scheme)")
}
if u.Fragment != "" || u.RawFragment != "" {
return errors.New("不可包含 fragment")
}
if u.User != nil {
return errors.New("不可包含 userinfo")
}
switch u.Scheme {
case "http", "https":
if u.Host == "" {
return errors.New("缺少 host")
}
if u.Scheme == "http" {
switch u.Hostname() {
case "localhost", "127.0.0.1", "::1":
default:
return errors.New("http 僅允許 loopback(localhost、127.0.0.1、::1),其餘請使用 https")
}
}
default:
// 自訂 scheme:僅有 scheme 而無其餘部分(如 "myapp:")無法作為回呼位址。
if u.Opaque == "" && u.Host == "" && u.Path == "" {
return errors.New("自訂 scheme 的 URI 須包含 scheme 以外的部分")
}
}
return nil
}
// GetByClientID 以 client_id 查詢應用程式,供 /authorize、
// /token 驗證 Client 身分;查無資料時回傳包裹 gorm.ErrRecordNotFound
// 的錯誤(以 errors.Is 判斷)。
func GetByClientID(db *gorm.DB, clientID string) (*Application, error) {
var a Application
if err := db.Where("client_id = ?", clientID).First(&a).Error; err != nil {
return nil, fmt.Errorf("query application: %w", err)
}
return &a, nil
}
+422
View File
@@ -0,0 +1,422 @@
package application
import (
"errors"
"fmt"
"os"
"reflect"
"strings"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func TestNewApplicationConfidential(t *testing.T) {
a, secret, err := NewApplication("示範應用", ClientConfidential,
[]string{"https://app.example.com/oidc/callback"},
[]GrantType{GrantAuthorizationCode, GrantRefreshToken},
"openid profile offline_access")
if err != nil {
t.Fatal("NewApplication: ", err)
}
if len(a.ClientID) != 22 {
t.Errorf("ClientID 應為 16 bytes 亂數的 base64url(22 字元),得到 %d 字元", len(a.ClientID))
}
if a.IsPublic() {
t.Error("機密式 Client 的 IsPublic() 應為 false")
}
if len(secret) != 43 {
t.Errorf("client secret 應為 32 bytes 亂數的 base64url(43 字元),得到 %d 字元", len(secret))
}
if !strings.HasPrefix(a.ClientSecretHash, "$argon2id$") {
t.Errorf("ClientSecretHash 應為 argon2id PHC 字串,得到 %q", a.ClientSecretHash)
}
if strings.Contains(a.ClientSecretHash, secret) {
t.Error("client secret 不應以明文出現在雜湊欄位")
}
if !a.CheckSecret(secret) {
t.Error("正確的 client secret 應驗證成功")
}
if a.CheckSecret("wrong-secret") {
t.Error("錯誤的 client secret 不應驗證成功")
}
if err := a.Validate(); err != nil {
t.Error("新建立的註冊資料應通過驗證: ", err)
}
}
func TestNewApplicationPublic(t *testing.T) {
a, secret, err := NewApplication("行動應用", ClientPublic,
[]string{"com.example.app:/oidc/callback"}, nil, "")
if err != nil {
t.Fatal("NewApplication: ", err)
}
if !a.IsPublic() {
t.Error("公開式 Client 的 IsPublic() 應為 true")
}
if secret != "" {
t.Errorf("公開式 Client 不應簽發 client secret,得到 %q", secret)
}
if a.ClientSecretHash != "" {
t.Errorf("公開式 Client 不應存 secret 雜湊,得到 %q", a.ClientSecretHash)
}
for _, s := range []string{"", "anything"} {
if a.CheckSecret(s) {
t.Errorf("公開式 Client 的 CheckSecret(%q) 應為 false", s)
}
}
if _, err := a.GenerateSecret(); err == nil {
t.Error("公開式 Client 呼叫 GenerateSecret 應回傳錯誤")
}
}
func TestNewApplicationDefaults(t *testing.T) {
a, _, err := NewApplication(" 示範應用 ", ClientConfidential,
[]string{"https://app.example.com/cb"}, nil, "")
if err != nil {
t.Fatal(err)
}
if a.Name != "示範應用" {
t.Errorf("名稱應去除首尾空白,得到 %q", a.Name)
}
if !reflect.DeepEqual(a.GrantTypes, GrantTypes{GrantAuthorizationCode}) {
t.Errorf("未指定 grant type 應預設 authorization_code,得到 %v", a.GrantTypes)
}
if a.Scope != defaultScope {
t.Errorf("未指定 scope 應預設 %q,得到 %q", defaultScope, a.Scope)
}
}
func TestNewApplicationClientIDUnique(t *testing.T) {
a, _, err := NewApplication("A", ClientPublic, []string{"https://a.example.com/cb"}, nil, "")
if err != nil {
t.Fatal(err)
}
b, _, err := NewApplication("B", ClientPublic, []string{"https://b.example.com/cb"}, nil, "")
if err != nil {
t.Fatal(err)
}
if a.ClientID == b.ClientID {
t.Error("兩次建立的 client_id 不應相同")
}
}
func TestNewApplicationClientCredentialsWithoutRedirectURIs(t *testing.T) {
// 僅 client_credentials 的機器對機器應用不經過瀏覽器,無須 redirect URI。
a, secret, err := NewApplication("批次服務", ClientConfidential, nil,
[]GrantType{GrantClientCredentials}, "openid")
if err != nil {
t.Fatal("僅 client_credentials 註冊不應要求 redirect URI: ", err)
}
if secret == "" || !a.CheckSecret(secret) {
t.Error("機密式 Client 應簽發可驗證的 client secret")
}
}
func TestNewApplicationInvalid(t *testing.T) {
cases := []struct {
desc string
name string
typ ClientType
uris []string
grants []GrantType
scope string
want string // 錯誤訊息應包含的子字串
}{
{"空名稱", "", ClientConfidential, []string{"https://a.example.com/cb"}, nil, "", "名稱"},
{"不支援的類型", "A", "webapp", []string{"https://a.example.com/cb"}, nil, "", "類型"},
{"不支援的 grant type", "A", ClientConfidential, []string{"https://a.example.com/cb"}, []GrantType{"implicit"}, "", "grant type"},
{"公開式使用 client_credentials", "A", ClientPublic, []string{"https://a.example.com/cb"}, []GrantType{GrantClientCredentials}, "", "client_credentials"},
{"refresh_token 未伴隨授權碼", "A", ClientConfidential, []string{"https://a.example.com/cb"}, []GrantType{GrantRefreshToken}, "", "refresh_token"},
{"授權碼流程無 redirect URI", "A", ClientConfidential, nil, []GrantType{GrantAuthorizationCode}, "", "redirect URI"},
{"相對 URI", "A", ClientConfidential, []string{"app.example.com/cb"}, nil, "", "絕對 URI"},
{"非 loopback 的 http", "A", ClientConfidential, []string{"http://app.example.com/cb"}, nil, "", "loopback"},
{"含 fragment", "A", ClientConfidential, []string{"https://app.example.com/cb#frag"}, nil, "", "fragment"},
{"含 userinfo", "A", ClientConfidential, []string{"https://user@app.example.com/cb"}, nil, "", "userinfo"},
{"缺少 host", "A", ClientConfidential, []string{"https:///cb"}, nil, "", "host"},
{"不支援的 scope", "A", ClientConfidential, []string{"https://a.example.com/cb"}, nil, "openid admin", "scope"},
{"offline_access 無 refresh_token grant", "A", ClientConfidential, []string{"https://a.example.com/cb"}, []GrantType{GrantAuthorizationCode}, "openid offline_access", "offline_access"},
}
for _, c := range cases {
_, _, err := NewApplication(c.name, c.typ, c.uris, c.grants, c.scope)
if err == nil {
t.Errorf("%s:應回傳錯誤", c.desc)
continue
}
if !strings.Contains(err.Error(), c.want) {
t.Errorf("%s:錯誤訊息 %q 應包含 %q", c.desc, err.Error(), c.want)
}
}
}
func TestApplicationSecretRotation(t *testing.T) {
a, secret1, err := NewApplication("示範應用", ClientConfidential,
[]string{"https://app.example.com/cb"}, nil, "")
if err != nil {
t.Fatal(err)
}
secret2, err := a.GenerateSecret()
if err != nil {
t.Fatal("GenerateSecret: ", err)
}
if secret1 == secret2 {
t.Error("輪替後的 client secret 不應與舊值相同")
}
if a.CheckSecret(secret1) {
t.Error("輪替後舊 client secret 應立即失效")
}
if !a.CheckSecret(secret2) {
t.Error("新 client secret 應驗證成功")
}
}
func TestApplicationUpdate(t *testing.T) {
a, secret, err := NewApplication("舊名稱", ClientConfidential,
[]string{"https://old.example.com/cb"},
[]GrantType{GrantAuthorizationCode}, "openid")
if err != nil {
t.Fatal(err)
}
oldID := a.ClientID
if err := a.Update(" 新名稱 ", ClientConfidential,
[]string{"https://new.example.com/cb", "https://alt.example.com/cb"},
[]GrantType{GrantAuthorizationCode, GrantRefreshToken}, "openid profile offline_access"); err != nil {
t.Fatal("Update: ", err)
}
if a.Name != "新名稱" {
t.Errorf("名稱應更新並去除首尾空白,得到 %q", a.Name)
}
if a.ClientID != oldID {
t.Errorf("client_id 不可因更新而變更:%q → %q", oldID, a.ClientID)
}
wantURIs := RedirectURIs{"https://new.example.com/cb", "https://alt.example.com/cb"}
if !reflect.DeepEqual(a.RedirectURIs, wantURIs) {
t.Errorf("RedirectURIs = %v, want %v", a.RedirectURIs, wantURIs)
}
if !a.GrantTypes.Contains(GrantRefreshToken) {
t.Errorf("GrantTypes 應更新,得到 %v", a.GrantTypes)
}
if !a.CheckSecret(secret) {
t.Error("更新註冊內容不應影響既有 client secret")
}
}
func TestApplicationUpdateDefaultsAndInvalid(t *testing.T) {
a, _, err := NewApplication("示範應用", ClientConfidential, []string{"https://a.example.com/cb"}, nil, "")
if err != nil {
t.Fatal(err)
}
// grant type 與 scope 留空時沿用註冊時的預設行為。
if err := a.Update("更新後", ClientConfidential, []string{"https://a.example.com/cb"}, nil, ""); err != nil {
t.Fatal("Update: ", err)
}
if !reflect.DeepEqual(a.GrantTypes, GrantTypes{GrantAuthorizationCode}) {
t.Errorf("未指定 grant type 應預設 authorization_code,得到 %v", a.GrantTypes)
}
if a.Scope != defaultScope {
t.Errorf("未指定 scope 應預設 %q,得到 %q", defaultScope, a.Scope)
}
if err := a.Update("示範應用", ClientConfidential, []string{"http://a.example.com/cb"}, nil, ""); err == nil {
t.Error("非法 redirect URI 的 Update 應回傳錯誤")
}
}
func TestApplicationUpdateToPublicClearsSecret(t *testing.T) {
a, secret, err := NewApplication("後端服務", ClientConfidential, []string{"https://a.example.com/cb"}, nil, "")
if err != nil {
t.Fatal(err)
}
if err := a.Update("後端服務", ClientPublic, []string{"https://a.example.com/cb"}, nil, ""); err != nil {
t.Fatal("Update: ", err)
}
if !a.IsPublic() {
t.Error("更新為公開式後 IsPublic() 應為 true")
}
if a.ClientSecretHash != "" {
t.Errorf("改為公開式應清除 secret 雜湊,得到 %q", a.ClientSecretHash)
}
if a.CheckSecret(secret) {
t.Error("改為公開式後舊 client secret 應失效")
}
// 改回機密式:雜湊不應復活,須以 GenerateSecret 重新輪替。
if err := a.Update("後端服務", ClientConfidential, []string{"https://a.example.com/cb"}, nil, ""); err != nil {
t.Fatal("Update 回機密式: ", err)
}
if a.ClientSecretHash != "" || a.CheckSecret(secret) {
t.Error("由公開式改回機密式不應復活舊 secret,須重新輪替")
}
newSecret, err := a.GenerateSecret()
if err != nil || !a.CheckSecret(newSecret) {
t.Error("改回機密式後應可重新輪替取得有效 secret")
}
}
func TestApplicationCheckSecretMalformedHash(t *testing.T) {
for _, hash := range []string{"", "not-a-phc-hash", "$argon2id$v=19$incomplete"} {
a := &Application{Type: ClientConfidential, ClientSecretHash: hash}
if a.CheckSecret("whatever") {
t.Errorf("格式無效的雜湊 %q 不應驗證成功", hash)
}
}
}
func TestRedirectURIsContains(t *testing.T) {
uris := RedirectURIs{"https://app.example.com/cb", "com.example.app:/cb"}
for _, uri := range []string{"https://app.example.com/cb", "com.example.app:/cb"} {
if !uris.Contains(uri) {
t.Errorf("已註冊的 %q 應比對成功", uri)
}
}
for _, uri := range []string{
"https://app.example.com/cb?x=1", // 未註冊的 query
"https://app.example.com/cb/", // 結尾斜線不同即不同字串
"https://evil.example.com/cb",
"HTTPS://APP.EXAMPLE.COM/cb",
"",
} {
if uris.Contains(uri) {
t.Errorf("未註冊的 %q 不應比對成功(須完全相同)", uri)
}
}
}
func TestGrantTypesContains(t *testing.T) {
gts := GrantTypes{GrantAuthorizationCode, GrantRefreshToken}
if !gts.Contains(GrantAuthorizationCode) || !gts.Contains(GrantRefreshToken) {
t.Error("已啟用的 grant type 應比對成功")
}
if gts.Contains(GrantClientCredentials) {
t.Error("未啟用的 grant type 不應比對成功")
}
}
func TestValidateRedirectURI(t *testing.T) {
valid := []string{
"https://app.example.com/oidc/callback",
"https://app.example.com", // 無 path
"https://app.example.com:8443/cb", // 帶 port
"http://localhost:8080/cb", // loopback 例外
"http://127.0.0.1/cb", // loopback IP
"http://[::1]:8080/cb", // IPv6 loopback
"com.example.app:/oidc/callback", // 原生應用自訂 scheme
"urn:ietf:wg:oauth:2.0:oob", // opaque URI
}
for _, uri := range valid {
if err := validateRedirectURI(uri); err != nil {
t.Errorf("redirect URI %q 應有效,得到錯誤:%v", uri, err)
}
}
invalid := []string{
"", // 空字串
"app.example.com/cb", // 相對 URI(無 scheme)
"/cb", // path only
"https://app.example.com/cb#frag", // fragment(RFC 6749 §3.1.2 禁止)
"https://user@app.example.com/cb", // userinfo
"https:///cb", // 無 host
"http://app.example.com/cb", // 非 loopback 的 http
"myapp:", // 僅有 scheme
}
for _, uri := range invalid {
if err := validateRedirectURI(uri); err == nil {
t.Errorf("redirect URI %q 應無效", uri)
}
}
}
// --- 整合測試:需要本機 PostgreSQL,連不上時跳過 ---
// envOrTest 讀取環境變數,空值時回傳 fallback(與 store.EnvOr 同邏輯;
// 測試不可匯入 internal/store——其 AutoMigrate 匯入本套件,會形成測試循環)。
func envOrTest(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// newTestDB 連線本機 PostgreSQL 並準備專用的 alterminal_test 資料庫
// (與開發資料庫 alterminal 隔離),僅遷移與清空 applications 資料表
// (本套件測試不涉及其他模型)。
func newTestDB(t *testing.T) *gorm.DB {
t.Helper()
admin, err := gorm.Open(postgres.Open(fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable TimeZone=UTC",
envOrTest("DB_HOST", "localhost"), envOrTest("DB_PORT", "5432"),
envOrTest("DB_USER", "postgres"), envOrTest("DB_PASSWORD", "postgres"),
)), &gorm.Config{})
if err != nil {
t.Skipf("本機 PostgreSQL 不可用,跳過整合測試:%v", err)
}
if err := admin.Exec("CREATE DATABASE alterminal_test").Error; err != nil && !strings.Contains(err.Error(), "already exists") {
t.Skipf("無法建立測試資料庫:%v", err)
}
db, err := gorm.Open(postgres.Open(fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=alterminal_test sslmode=disable TimeZone=UTC",
envOrTest("DB_HOST", "localhost"), envOrTest("DB_PORT", "5432"),
envOrTest("DB_USER", "postgres"), envOrTest("DB_PASSWORD", "postgres"),
)), &gorm.Config{TranslateError: true})
if err != nil {
t.Skipf("連線測試資料庫失敗:%v", err)
}
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
sqlDB.Close()
}
})
if err := db.AutoMigrate(&Application{}); err != nil {
t.Fatalf("遷移測試資料表失敗:%v", err)
}
if err := db.Exec("TRUNCATE applications RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("清空測試資料失敗:%v", err)
}
return db
}
func TestApplicationPersistence(t *testing.T) {
db := newTestDB(t)
a, secret, err := NewApplication("示範應用", ClientConfidential,
[]string{"https://app.example.com/oidc/callback", "https://app.example.com/other"},
[]GrantType{GrantAuthorizationCode, GrantRefreshToken},
"openid profile email offline_access")
if err != nil {
t.Fatal(err)
}
if err := db.Create(a).Error; err != nil {
t.Fatalf("建立應用程式失敗:%v", err)
}
got, err := GetByClientID(db, a.ClientID)
if err != nil {
t.Fatalf("以 client_id 查詢失敗:%v", err)
}
if got.ID == 0 || got.Name != a.Name || got.Type != a.Type || got.Scope != a.Scope {
t.Errorf("基本欄位往返不一致:got %+v", got)
}
if !reflect.DeepEqual(got.RedirectURIs, a.RedirectURIs) {
t.Errorf("RedirectURIs 往返不一致:got %v want %v", got.RedirectURIs, a.RedirectURIs)
}
if !reflect.DeepEqual(got.GrantTypes, a.GrantTypes) {
t.Errorf("GrantTypes 往返不一致:got %v want %v", got.GrantTypes, a.GrantTypes)
}
if !got.CheckSecret(secret) {
t.Error("資料庫往返後 client secret 應仍可驗證")
}
if !got.RedirectURIs.Contains("https://app.example.com/oidc/callback") {
t.Error("往返後 redirect URI 比對應仍可用")
}
dup, _, err := NewApplication("重複測試", ClientPublic,
[]string{"https://dup.example.com/cb"}, nil, "")
if err != nil {
t.Fatal(err)
}
dup.ClientID = a.ClientID
if err := db.Create(dup).Error; !errors.Is(err, gorm.ErrDuplicatedKey) {
t.Errorf("重複的 client_id 應回 gorm.ErrDuplicatedKey,得到 %v", err)
}
if _, err := GetByClientID(db, "no-such-client"); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("查無 client_id 應回 gorm.ErrRecordNotFound,得到 %v", err)
}
}