# OIDC 核心實作計畫:Discovery、/authorize、/token、/userinfo、Refresh Token 範圍(已確認):完整授權碼流程 + PKCE、refresh token 輪替與重用偵測、/userinfo;同意頁採「首次同意後記住」。全部新增程式碼以繁體中文註解並引用 RFC 條號,JSON 回應統一 `auth.WriteJSON`/`auth.WriteError`。 ## 0. 關鍵前置:套件循環調整 - 新模型(見 §1)放 `internal/oidc`;`store.Open()` 的 AutoMigrate 加入它們 → `store` 匯入 `oidc`。 - `internal/oidc/jwks_test.go` 改為外部測試套件 `package oidc_test`(否則 `oidc(測試) → testdb → store → oidc` 形成循環)。helper(`jwksGet`、`mustNewKey`)在 `package oidc_test` 內跨檔共用不受影響;純邏輯測試(JWT、hash helper)可留 `package oidc` 內部測試,兩者可共存。 ## 1. 新資料模型(internal/oidc/model.go) 授權碼與 refresh token 皆為高熵亂數、兌換時無 key 可查(不同于 client secret 有 client_id 可查),故存 **SHA-256 雜湊**(base64url,43 字元)供索引查詢,明文只在回應中出現一次: ```go // AuthorizationCode:一次性授權碼,TTL 5 分鐘(RFC 6749 §4.1.2 建議最長 10 分鐘) type AuthorizationCode struct { ID uint; CodeHash string `gorm:"uniqueIndex;size:43;not null"` ApplicationID, UserID uint `gorm:"not null;index"` RedirectURI string; Scope string; Nonce string CodeChallenge string; CodeChallengeMethod string // PKCE;public client 必有 ExpiresAt time.Time `gorm:"not null"`; UsedAt *time.Time CreatedAt, UpdatedAt time.Time } // RefreshToken:TTL 30 天;RotatedAt=已輪替、RevokedAt=已撤銷 type RefreshToken struct { ID uint; TokenHash string `gorm:"uniqueIndex;size:43;not null"` ApplicationID, UserID uint; Scope string ExpiresAt time.Time; RotatedAt *time.Time; RevokedAt *time.Time CreatedAt, UpdatedAt time.Time } // Consent:使用者×應用 的已同意 scope 聯集,(user_id, application_id) 唯一索引 type Consent struct { ID, UserID, ApplicationID uint; Scope string; CreatedAt, UpdatedAt time.Time } ``` 輔助函式:`sha256Token(s)`、`NewAuthorizationCode(db, ...)`(產碼+順帶刪過期碼,仿 `CreateSession` 的 best-effort 清理)、`NewRefreshToken(db, ...)`、`ConsumeAuthorizationCode(db, hash)`(`WHERE used_at IS NULL` 條件更新防 race,affected=0 視為重用)。 ## 2. JWT 簽發/驗證(internal/oidc/jwt.go,純 stdlib) 沿用專案「零第三方 JWT 庫、手作 JWK」方針:`SignJWT(key *jwk.SigningKey, claims map[string]any) (string, error)` 組 `{"alg":"RS256","kid":...,"typ":"JWT"}` header+claims,`rsa.SignPKCS1v15(SHA-256)` 簽章,三段 base64url。驗證端 `VerifyAccessToken(db, issuer, token) (*AccessTokenClaims, error)`:alg 固定 RS256(拒絕 alg 混淆)、kid 查 `signing_keys`(含已退休者,供輪替過渡期驗證)、`rsa.VerifyPKCS1v15`、比對 iss/exp。 Claims: - Access token(TTL 15 分鐘):`iss`、`sub`(user ID 字串)、`aud`(client_id)、`exp`、`iat`、`scope`、`client_id`。自包含不落庫(README 已定案)。 - ID token(TTL 15 分鐘):`iss`、`sub`、`aud`、`exp`、`iat`、`auth_time`(Session 建立時間)、`nonce`;依 scope 加 `name`/`preferred_username`(profile)、`email`/`email_verified`(email)。 ## 3. Discovery(internal/oidc/discovery.go) `DiscoveryHandler(issuer string) http.HandlerFunc`:issuer 於 main 以 `store.EnvOr("ISSUER", "http://localhost:8080")` 讀取並去尾斜線後**以參數注入**(便於測試、oidc 不匯入 store)。回應含 issuer、authorization/token/userinfo endpoint、jwks_uri、`scopes_supported`(經 `application` 套件新增 exported `ScopesSupported() []string`,單一真相來源)、`grant_types_supported: [authorization_code, refresh_token]`、`response_types_supported: [code]`、`subject_types_supported: [public]`、`id_token_signing_alg_values_supported: [RS256]`、`token_endpoint_auth_methods_supported: [client_secret_basic, client_secret_post, none]`、`code_challenge_methods_supported: [S256]`、`claims_supported`;`Cache-Control: public, max-age=3600`(與 JWKS 一致)。 ## 4. /authorize(internal/oidc/authorize.go)+同意頁 `AuthorizeHandler(db *gorm.DB) http.HandlerFunc`,GET 與 POST 掛同一工廠: **共用驗證**(query 或表單):`response_type=code`、`client_id` → `application.GetByClientID`、`redirect_uri` 精確比對 `RedirectURIs.Contains`(RFC 6749 §3.1.2.3)。**client/redirect_uri 無效 → 直接 400 錯誤頁,不重導**(RFC 6749 §4.1.2.1,防導向攻擊);其餘錯誤(scope 不含 openid(OIDC Core §3.1.2.1)、scope 超出 app 註冊、`code_challenge_method` 非 S256(RFC 7636 §4.2,不允許 plain)、public client 無 PKCE、app 未啟用授權碼 grant)→ 302 `redirect_uri?error=...&state=...`。 **GET 流程**:驗證通過後查 Session cookie——無效 → 303 `/login?next=<完整 /authorize URL>`(§7 的 login 改動);有效 → 查 Consent:請求 scope ⊆ 已同意 scope → 直接產碼 302;否則渲染同意頁。 **同意頁**:新模板 `internal/auth/templates/consent.html`(採 layout 側欄版面,資料含 Username/Email/CSRF/應用名稱/scope 人類可讀清單),由 auth 匯出 `ConsentTmpl`(仿 `AdminKeysTmpl` 慣例),oidc 以 `auth.RenderHTML` 渲染。表單以 hidden fields 帶全部原始參數,POST `/authorize`。 **POST 流程**:ParseForm → `auth.VerifyCSRF` → 重跑共用驗證與 Session 檢查 → `decision=allow` → upsert Consent(scope 聯集)+產碼 302;`decision=deny` → 302 `redirect_uri?error=access_denied&state=...`。產碼以 `url.Parse` 正確附加 query(redirect_uri 可能自帶 query)。 ## 5. /token(internal/oidc/token.go) `TokenHandler(db, issuer) http.HandlerFunc`,僅 POST、`application/x-www-form-urlencoded`。 **Client 認證**(RFC 6749 §2.3.1):Basic(`r.BasicAuth()`,對 client_id/secret 做相容性 QueryUnescape)或 body 的 `client_id`+`client_secret`;兩處 client_id 不一致 → invalid_request;confidential 驗 `CheckSecret` 失敗 → 401 `invalid_client`(Basic 時帶 `WWW-Authenticate: Basic`);public 不驗 secret。 **grant_type=authorization_code**:查碼(hash)→ 不存在/過期 → `invalid_grant`;`UsedAt != nil` 為重用 → `invalid_grant` 並撤銷該碼已發的 refresh token(OAuth Security BCP 重用防護);ApplicationID/redirect_uri 不符 → `invalid_grant`;PKCE:碼有 challenge 時驗 `BASE64URL(SHA256(verifier)) == challenge`;以條件更新標記 UsedAt 後簽發 access+ID token(+ scope 含 offline_access 且 app 有 refresh grant 時發 refresh token)。成功回應含 `Cache-Control: no-store`(RFC 6749 §5.1)。 **grant_type=refresh_token**:app 須有 refresh grant;查 token hash——撤銷/過期/不存在 → `invalid_grant`;`RotatedAt != nil` 為重用 → **撤銷該 user×app 全部 refresh token** 後 `invalid_grant`;有效 → 條件更新 `rotated_at` 後發新 refresh token(沿用原 scope)+新 access/ID token。 錯誤格式 `{"error": "...", "error_description": "..."}`(RFC 6749 §5.2),新增小 helper `writeTokenError`。 ## 6. /userinfo(internal/oidc/userinfo.go) `UserInfoHandler(db, issuer)`,GET/POST。Bearer token 取自 `Authorization` header(POST 亦接受 form `access_token`,RFC 6750);缺 token → 401+`WWW-Authenticate: Bearer`;`VerifyAccessToken` 失敗 → 401+`error="invalid_token"`;scope 無 openid → 403 `insufficient_scope`;成功回 `sub`+依 scope 的 `name`/`preferred_username`/`email`/`email_verified`。 ## 7. auth 套件改動(login 支援 next) - `GET /login?next=...`:next 驗證為站內路徑(以 `/` 開頭且不以 `//` 開頭,防 open redirect,不合格一律回 `/`);已登入時改導 next;`loginPageData` 加 `Next`、`login.html` 加 hidden input、失敗重繪保留。 - `POST /login` 表單流程成功後 303 導向 next(無 next 行為不變);JSON API 流程不動。 ## 8. 路由與環境變數 `cmd/alterminal/main.go`:讀 `issuer := strings.TrimSuffix(store.EnvOr("ISSUER", "http://localhost:8080"), "/")`,註冊 `GET /.well-known/openid-configuration`、`GET|POST /authorize`、`POST /token`、`GET|POST /userinfo`。 `internal/store/db.go`:AutoMigrate 加入三個新模型(匯入 oidc)。`internal/testdb/testdb.go`:TRUNCATE 加 `authorization_codes, refresh_tokens, consents`。 ## 9. 測試 - `internal/oidc`:整合測試 `package oidc_test` 用 `testdb.New(t)`——discovery(欄位與 issuer 前綴)、jwt(往返、竄改 payload、alg=none、過期、kid 不存在)、authorize(client/redirect_uri 無效不重導、參數錯誤重導帶 error+state、未登入 303 /login?next、同意/拒絕、同意後靜默通過、scope 擴大再詢問)、token(PKCE 兌換、code 重用撤銷、redirect_uri 不符、verifier 錯、Basic/post 認證、401 invalid_client、refresh 輪替、重用整鏈撤銷、過期)、userinfo(claims 依 scope、401/403)。table-driven+中文子測試名+`strings.Contains`,全套從 authorize→login→consent→token→userinfo 走通一個 e2e。 - `internal/auth`:login next 流程(成功導向、已登入導向 next、外站 next 導 `/`)。 - 既有測試不動語意,僅 `jwks_test.go` 改 package 宣告。 ## 10. 收尾 - Tailwind rebuild:`tools/tailwindcss -i internal/auth/assets/css/input.css -o internal/auth/assets/css/main.css --minify`(consent.html 的 class 進產物;consent.html 盡量沿用既有 class)。 - README:端點表與 Roadmap 狀態更新、`ISSUER` 環境變數改為已使用並附說明。 - 驗證:`go build ./...`、`go vet ./...`、`go test ./...`(本機 PostgreSQL 存在時跑整合測試,否則 skip)。