鉴权并入 Auth Broker:委派设备会话统一模型 + 四端迁移
后端(委托 Auth Broker,路径 A): - 删自建鉴权(OIDC exchange / 自签会话 / step-up / shortcut / web_sessions / accounts),cdrop 不再存任何凭证;鉴权中间件改读边缘注入的 X-Auth-Subject/Scope/Meta/Name/Roles 头(dev 旁路保留);Claims 加 Tier() / Guest() - internal/brokerclient:mint / revoke(带 X-Broker-App)/ refresh / ListSessions(R1 列举),直连内网、吊销幂等 统一会话模型“委派设备会话”(Delegated Device Sessions): - 每个客户端(浏览器 / 桌面 / 扫码设备)=一条带 meta(device_id) + label 的 broker 机器会话;Broker 作设备会话唯一注册表(R1 按用户+app 列举 + R2 按 (user,app,meta) 幂等铸造),cdrop 退化为薄覆盖层、不再自存权威会话表 - 新增代铸端点 POST /api/auth/device-session:凭边缘已验明的 X-Auth-Subject 委托 broker 铸 / 轮换设备会话(meta=device_id、按调用方 tier 防越权、sameOrigin CSRF、per-IP 限流);R2 幂等保证同一 device_id 重登原地轮换、不堆重复设备 - 会话列表=R1 权威 + 叠加 type(本地缓存)/ online(Hub presence,按设备名)/ current(meta 匹配本请求 X-Auth-Meta)+ 过滤 meta=""(device-authorize 引导会话残留);devices 表降级为 type/presence 薄缓存(非会话权威),device_id 主键、upsert 按 user 限定 - 吊销按 device_id → 缓存优先 / R1 兜底解析 sid → broker 吊销 + X-Broker-App;扫码登录保留三密钥编排,collect 改委托 broker 铸 + 落缓存行 Web 前端: - 登录走 broker 全局 SSO 代跳(/api/auth/login 302);bootstrap 经 /api/me 注入身份后代铸设备会话(稳定 device_id 存 localStorage、Web Locks 跨 tab 串行防重复铸造);refresh 走 /api/auth/refresh - 设备管理按 device_id;改名=同 device_id 重代铸(R2 原地轮换换 label、不产生重复行);登录页反应式守卫修登录回环 - 去 OIDC PKCE / step-up(删 oauth.callback / stepUp) 桌面客户端(Wails): - loopback PKCE(RFC 8252)改指 broker 设备授权流(/device/authorize + /device/token)拿引导令牌,再代铸出带 meta 的托管设备会话——与浏览器同模型、同管理、同吊销;身份取自代铸响应(修“显示名显示为 UUID”);refresh 保留显示名;稳定 device_id 入桌面配置 iOS 客户端(arch A,原生 SwiftUI + 离屏无头 WebView 引擎 + 原生↔JS 桥): - 引擎 / 文件管理 / 设备管理 / 应用图标 / 本地化(此前实现,随本次落入版本库) - 鉴权=引擎自刷(boot 注入 refresh_token)+ broker 轮换经 sessionRotated 回报原生更新 Keychain;去 cookie 同步;Session 加 refreshToken / deviceId 实时 / 健壮性: - presence 走 Hub union(设备表行 ∪ 表外实时连接,按名去重、live-only 标在线) - Hub 通道 close 一律在写锁内、非阻塞 send 一律在读锁内,消除 close-vs-send 闭通道 send panic(revoke 每次 Kick 后该路径变热) 配置 / 删旧栈: - config 改 broker 接入(CDROP_BROKER_* / CDROP_PUBLIC_URL / 按档 TTL),prod 强校验 broker 配置 + PUBLIC_URL(CSRF Origin 守卫不失效) - 删 auth.go / selftoken.go / shortcut.go / jwks.go + 三表(web_sessions / accounts / shortcut_tokens)及验证链;.env.example / compose.snippet.yaml / Caddyfile.snippet 更新为 broker 模型(人机分流 + 公开端点放行 + X-Auth-Meta 透传) - 测试全重写:QR / 会话含 mock broker(R1 列举 + R2 幂等);hub 加 close-vs-send 并发回归;config 加 prod 必填校验
This commit is contained in:
+77
-340
@@ -2,354 +2,131 @@ package jwtauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
|
||||
"commilitia.net/cdrop/internal/config"
|
||||
"commilitia.net/cdrop/internal/db"
|
||||
)
|
||||
|
||||
// Store is the subset of *db.Queries the auth middleware uses: device upserts,
|
||||
// the shortcut-token lookups needed to honour revocation, and the web_session
|
||||
// lookup that makes a self-signed session token's revocation take effect at once
|
||||
// (a deleted row → the token is rejected on its next request, not after TTL).
|
||||
// Declared as an interface so tests can swap in a fake.
|
||||
// Store is the subset of *db.Queries the auth middleware uses: refreshing a managed
|
||||
// device's last_seen + tier on each request. Declared as an interface so tests can
|
||||
// swap in a fake.
|
||||
type Store interface {
|
||||
UpsertDevice(ctx context.Context, arg db.UpsertDeviceParams) error
|
||||
GetShortcutToken(ctx context.Context, jti string) (db.ShortcutToken, error)
|
||||
TouchShortcutTokenUsed(ctx context.Context, arg db.TouchShortcutTokenUsedParams) error
|
||||
GetWebSession(ctx context.Context, id string) (db.WebSession, error)
|
||||
TouchDevice(ctx context.Context, arg db.TouchDeviceParams) error
|
||||
}
|
||||
|
||||
// Authenticator turns each request's identity into Claims. After the Auth Broker
|
||||
// migration (path A) cdrop no longer verifies tokens itself: in prod the broker
|
||||
// authenticates at the edge and injects X-Auth-* headers this process trusts; in dev
|
||||
// the claims are synthesised from the dev token.
|
||||
type Authenticator struct {
|
||||
cfg *config.Config
|
||||
store Store
|
||||
jwks *jwksCache
|
||||
hsKey []byte
|
||||
sessionTokenKey []byte
|
||||
cfg *config.Config
|
||||
store Store
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, store Store) *Authenticator {
|
||||
a := &Authenticator{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
}
|
||||
if cfg.HS256Secret != "" {
|
||||
a.hsKey = DeriveHS256Key(cfg.HS256Secret)
|
||||
}
|
||||
// Self-signed session tokens (scan-login) are keyed off SessionSecret; nil
|
||||
// when unset (dev) disables verifySelfToken, matching the minting side.
|
||||
a.sessionTokenKey = DeriveSessionTokenKey(cfg.SessionSecret)
|
||||
if cfg.AuthMode == "prod" && cfg.OIDCJWKSURL != "" {
|
||||
a.jwks = newJWKSCache(cfg.OIDCJWKSURL, 10*time.Minute)
|
||||
}
|
||||
return a
|
||||
return &Authenticator{cfg: cfg, store: store}
|
||||
}
|
||||
|
||||
func (a *Authenticator) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := bearerToken(r)
|
||||
if !ok {
|
||||
unauthorized(w, "missing bearer token")
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := a.verify(r.Context(), token, r)
|
||||
claims, err := a.authenticate(r)
|
||||
if err != nil {
|
||||
slog.Warn("auth failed", "err", err, "path", r.URL.Path)
|
||||
unauthorized(w, "invalid token")
|
||||
unauthorized(w, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
deviceName := SanitizeDeviceName(r.Header.Get("X-Device-Name"))
|
||||
deviceType := normalizeDeviceType(r.Header.Get("X-Device-Type"))
|
||||
if claims.Scoped() {
|
||||
// The backend authoritatively knows a scoped token is the iOS
|
||||
// Shortcut, so it labels the device "shortcut" regardless of any
|
||||
// header. ("ios" stays reserved for a real native client.)
|
||||
deviceType = "shortcut"
|
||||
}
|
||||
|
||||
// Register a device only when the client names itself (X-Device-Name).
|
||||
// A nameless request — e.g. a polling shortcut hitting /api/clipboard/version
|
||||
// without the header — must NOT be registered: the old random UA fallback
|
||||
// minted a fresh "Unknown Device" row on every request and flooded the list.
|
||||
if deviceName != "" {
|
||||
if err := a.store.UpsertDevice(r.Context(), db.UpsertDeviceParams{
|
||||
UserID: claims.UserID,
|
||||
Name: deviceName,
|
||||
Type: deviceType,
|
||||
// Keep the managed device's last_seen + tier fresh. Identity is the
|
||||
// broker-issued device_id (X-Auth-Meta); the row is created at scan-login
|
||||
// collect, so this only ever updates — an unmanaged caller (no device_id,
|
||||
// e.g. a global SSO browser) is skipped, and a missing row no-ops.
|
||||
if claims.DeviceID != "" {
|
||||
if err := a.store.TouchDevice(r.Context(), db.TouchDeviceParams{
|
||||
LastSeen: time.Now().Unix(),
|
||||
Tier: claims.Tier(),
|
||||
DeviceID: claims.DeviceID,
|
||||
UserID: claims.UserID,
|
||||
}); err != nil {
|
||||
// non-fatal: log and continue so transient DB errors don't 401 users
|
||||
slog.Error("device upsert failed",
|
||||
"err", err, "user", claims.UserID, "device", deviceName)
|
||||
// non-fatal: log and continue so a transient DB error doesn't 401 users
|
||||
slog.Error("device touch failed",
|
||||
"err", err, "user", claims.UserID, "device", claims.DeviceID)
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), claimsCtxKey, claims)
|
||||
ctx := ContextWithClaims(r.Context(), claims)
|
||||
ctx = context.WithValue(ctx, deviceCtxKey, deviceName)
|
||||
ctx = context.WithValue(ctx, deviceTypeCtxKey, deviceType)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Authenticator) verify(ctx context.Context, token string, r *http.Request) (*Claims, error) {
|
||||
// authenticate resolves the request identity. prod trusts the broker's edge-injected
|
||||
// X-Auth-* headers; dev synthesises claims from the dev token.
|
||||
func (a *Authenticator) authenticate(r *http.Request) (*Claims, error) {
|
||||
if a.cfg.AuthMode == "dev" {
|
||||
return a.verifyDev(token, r)
|
||||
return a.devClaims(r)
|
||||
}
|
||||
// cdrop self-signed session tokens first: distinct key + typ=session, so a
|
||||
// shortcut token (different key, requires jti) never validates here and a
|
||||
// session token never falls through to the shortcut path's DB lookup.
|
||||
if c, err := a.verifySelfToken(ctx, token); err == nil {
|
||||
return c, nil
|
||||
// prod: the request reached cdrop only by passing broker /verify at the edge,
|
||||
// which injected these headers. Caddy strips any client-supplied X-Auth-* at the
|
||||
// trust boundary, so their presence is the broker's say-so. No subject → the
|
||||
// request did not authenticate.
|
||||
sub := r.Header.Get("X-Auth-Subject")
|
||||
if sub == "" {
|
||||
return nil, errors.New("missing X-Auth-Subject (request did not pass broker /verify)")
|
||||
}
|
||||
if c, err := a.verifyHS256(ctx, token); err == nil {
|
||||
return c, nil
|
||||
}
|
||||
return a.verifyRS256(ctx, token)
|
||||
return &Claims{
|
||||
UserID: sub,
|
||||
Name: r.Header.Get("X-Auth-Name"),
|
||||
Avatar: r.Header.Get("X-Auth-Avatar"),
|
||||
Groups: splitRoles(r.Header.Get("X-Auth-Roles")),
|
||||
Scope: r.Header.Get("X-Auth-Scope"),
|
||||
DeviceID: r.Header.Get("X-Auth-Meta"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// verifySelfToken validates a cdrop self-signed session access token (AUTH.md
|
||||
// §3.1): HS256 over DeriveSessionTokenKey, carrying typ=session and a full/guest
|
||||
// scope. This is the unified browser token — scan-login (self/guest) AND OIDC web
|
||||
// logins both ride it now, so the browser holds one token type. After the cheap
|
||||
// signature/exp/scope checks it does ONE indexed lookup of the token's sid against
|
||||
// web_sessions: a deleted row means the session was revoked, and the token is
|
||||
// rejected on its very next request (immediate "log out this device"). The IdP
|
||||
// RS256 path (verifyRS256) remains only for the desktop client's loopback tokens.
|
||||
func (a *Authenticator) verifySelfToken(ctx context.Context, token string) (*Claims, error) {
|
||||
if len(a.sessionTokenKey) == 0 {
|
||||
return nil, errors.New("session token key not configured")
|
||||
}
|
||||
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var std jwt.Claims
|
||||
custom := map[string]any{}
|
||||
if err := parsed.Claims(a.sessionTokenKey, &std, &custom); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := std.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, 30*time.Second); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if typ, _ := custom["typ"].(string); typ != "session" {
|
||||
return nil, errors.New("not a session token")
|
||||
}
|
||||
if std.Subject == "" {
|
||||
return nil, errors.New("session token missing subject")
|
||||
}
|
||||
scope, _ := custom["scope"].(string)
|
||||
if scope != "full" && scope != "guest" {
|
||||
return nil, errors.New("session token invalid scope")
|
||||
}
|
||||
// Bind the token to its live web_session row (sid): once that row is revoked
|
||||
// (deleted), the token is rejected on its very next request — "log out this
|
||||
// device" takes effect immediately rather than after the access token's TTL.
|
||||
sid, _ := custom["sid"].(string)
|
||||
if sid == "" {
|
||||
return nil, errors.New("session token missing sid")
|
||||
}
|
||||
if _, err := a.store.GetWebSession(ctx, sid); err != nil {
|
||||
return nil, errors.New("session revoked")
|
||||
}
|
||||
return &Claims{UserID: std.Subject, SessionScope: scope}, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) verifyDev(token string, r *http.Request) (*Claims, error) {
|
||||
if subtle.ConstantTimeCompare([]byte(token), []byte(a.cfg.DevToken)) != 1 {
|
||||
// devClaims authenticates the local dev token and synthesises claims. X-Dev-User sets
|
||||
// the subject (default "dev-user"); X-Dev-Scope simulates a tier ("guest" → restricted,
|
||||
// else full); X-Dev-Device optionally sets a device_id to exercise device flows.
|
||||
func (a *Authenticator) devClaims(r *http.Request) (*Claims, error) {
|
||||
token, ok := bearerToken(r)
|
||||
if !ok || subtle.ConstantTimeCompare([]byte(token), []byte(a.cfg.DevToken)) != 1 {
|
||||
return nil, errors.New("invalid dev token")
|
||||
}
|
||||
userID := r.Header.Get("X-Dev-User")
|
||||
if userID == "" {
|
||||
userID = "dev-user"
|
||||
}
|
||||
return &Claims{UserID: userID, Groups: []string{"dev"}}, nil
|
||||
scope := r.Header.Get("X-Dev-Scope")
|
||||
if scope == "" {
|
||||
scope = "full"
|
||||
}
|
||||
return &Claims{
|
||||
UserID: userID,
|
||||
Name: userID,
|
||||
Groups: []string{"dev"},
|
||||
Scope: scope,
|
||||
DeviceID: r.Header.Get("X-Dev-Device"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) verifyHS256(ctx context.Context, token string) (*Claims, error) {
|
||||
if len(a.hsKey) == 0 {
|
||||
return nil, errors.New("HS256 secret not configured")
|
||||
// splitRoles parses a comma-separated X-Auth-Roles header into a role slice,
|
||||
// trimming whitespace and dropping empties.
|
||||
func splitRoles(raw string) []string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var std jwt.Claims
|
||||
custom := map[string]any{}
|
||||
if err := parsed.Claims(a.hsKey, &std, &custom); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := std.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, 30*time.Second); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, err := claimsFromJWT(std, custom)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// HS256 is only ever used to mint scoped shortcut tokens, and those ALWAYS
|
||||
// carry a jti. A validly-signed HS256 token without one must be rejected
|
||||
// outright: otherwise it would fall through here as a full, unscoped account
|
||||
// session with a self-declared subject — far beyond the clipboard-only
|
||||
// surface HS256 is meant for. Requiring the jti keeps a leaked HS256 secret's
|
||||
// blast radius pinned to the shortcut scope (clipboard, and nothing else).
|
||||
if std.ID == "" {
|
||||
return nil, errors.New("HS256 token missing jti")
|
||||
}
|
||||
|
||||
// The signature alone is not enough — the token must still be present and not
|
||||
// revoked in the store, so a leaked or retired token can be killed
|
||||
// server-side. The stored row is also the authoritative source of scopes
|
||||
// (never trust scopes off the wire).
|
||||
row, err := a.store.GetShortcutToken(ctx, std.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shortcut token lookup: %w", err)
|
||||
}
|
||||
if row.Revoked != 0 {
|
||||
return nil, errors.New("shortcut token revoked")
|
||||
}
|
||||
if row.UserID != claims.UserID {
|
||||
return nil, errors.New("shortcut token subject mismatch")
|
||||
}
|
||||
claims.JTI = std.ID
|
||||
claims.Scopes = strings.Fields(row.Scopes)
|
||||
a.touchTokenAsync(std.ID)
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// touchTokenAsync records a shortcut token's last-use time off the request path
|
||||
// — it's audit metadata, so a slow or failing write must never delay or fail
|
||||
// the request it belongs to.
|
||||
func (a *Authenticator) touchTokenAsync(jti string) {
|
||||
now := time.Now().Unix()
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := a.store.TouchShortcutTokenUsed(ctx, db.TouchShortcutTokenUsedParams{
|
||||
LastUsedAt: &now,
|
||||
Jti: jti,
|
||||
}); err != nil {
|
||||
slog.Warn("touch shortcut token failed", "err", err, "jti", jti)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *Authenticator) verifyRS256(ctx context.Context, token string) (*Claims, error) {
|
||||
if a.jwks == nil {
|
||||
return nil, errors.New("JWKS not configured")
|
||||
}
|
||||
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(parsed.Headers) == 0 {
|
||||
return nil, errors.New("missing token headers")
|
||||
}
|
||||
kid := parsed.Headers[0].KeyID
|
||||
key, err := a.jwks.GetKey(ctx, kid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var std jwt.Claims
|
||||
custom := map[string]any{}
|
||||
if err := parsed.Claims(key, &std, &custom); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expected := jwt.Expected{Time: time.Now()}
|
||||
if a.cfg.OIDCIssuer != "" {
|
||||
expected.Issuer = a.cfg.OIDCIssuer
|
||||
}
|
||||
if a.cfg.OIDCAudience != "" {
|
||||
expected.AnyAudience = parseAudiences(a.cfg.OIDCAudience)
|
||||
}
|
||||
if err := std.ValidateWithLeeway(expected, 30*time.Second); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claimsFromJWT(std, custom)
|
||||
}
|
||||
|
||||
// VerifyIDToken validates an OIDC id_token (RS256 via JWKS, issuer + audience)
|
||||
// from a fresh prompt=login exchange and returns its subject and auth_time (the
|
||||
// epoch second of the actual end-user authentication, or 0 when the IdP omits the
|
||||
// claim — Casdoor does). Used for step-up re-auth (AUTH.md §6): the caller checks
|
||||
// sub matches and, only when auth_time is present, that it is recent enough.
|
||||
func (a *Authenticator) VerifyIDToken(ctx context.Context, token string) (subject string, authTime int64, err error) {
|
||||
if a.jwks == nil {
|
||||
return "", 0, errors.New("JWKS not configured")
|
||||
}
|
||||
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256})
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if len(parsed.Headers) == 0 {
|
||||
return "", 0, errors.New("missing token headers")
|
||||
}
|
||||
key, err := a.jwks.GetKey(ctx, parsed.Headers[0].KeyID)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
var std jwt.Claims
|
||||
custom := map[string]any{}
|
||||
if err := parsed.Claims(key, &std, &custom); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
expected := jwt.Expected{Time: time.Now()}
|
||||
if a.cfg.OIDCIssuer != "" {
|
||||
expected.Issuer = a.cfg.OIDCIssuer
|
||||
}
|
||||
if a.cfg.OIDCAudience != "" {
|
||||
expected.AnyAudience = parseAudiences(a.cfg.OIDCAudience)
|
||||
}
|
||||
if err := std.ValidateWithLeeway(expected, 30*time.Second); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if std.Subject == "" {
|
||||
return "", 0, errors.New("missing subject claim")
|
||||
}
|
||||
// auth_time is OPTIONAL: required by OIDC only when the IdP chooses to honour
|
||||
// max_age, and Casdoor omits it entirely. Absent → 0; the caller treats the
|
||||
// fresh single-use prompt=login code as the freshness bound instead.
|
||||
at, _ := numericClaim(custom["auth_time"])
|
||||
return std.Subject, at, nil
|
||||
}
|
||||
|
||||
// numericClaim coerces a JSON number claim (float64 from stdlib unmarshal, or
|
||||
// json.Number / int64) to int64.
|
||||
func numericClaim(v any) (int64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n), true
|
||||
case int64:
|
||||
return n, true
|
||||
case json.Number:
|
||||
if i, err := n.Int64(); err == nil {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// parseAudiences splits a comma-separated OIDCAudience config into a jwt.Audience
|
||||
// set. Multiple values let one backend accept tokens minted for several OAuth
|
||||
// clients (the web app and the desktop client carry different `aud`); go-jose's
|
||||
// AnyAudience passes when the token's audience matches any one entry. Whitespace
|
||||
// around entries is trimmed and empties dropped, so a plain single value behaves
|
||||
// exactly as before.
|
||||
func parseAudiences(raw string) jwt.Audience {
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make(jwt.Audience, 0, len(parts))
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if s := strings.TrimSpace(p); s != "" {
|
||||
out = append(out, s)
|
||||
@@ -358,21 +135,6 @@ func parseAudiences(raw string) jwt.Audience {
|
||||
return out
|
||||
}
|
||||
|
||||
func claimsFromJWT(std jwt.Claims, custom map[string]any) (*Claims, error) {
|
||||
if std.Subject == "" {
|
||||
return nil, errors.New("missing subject claim")
|
||||
}
|
||||
c := &Claims{UserID: std.Subject}
|
||||
if g, ok := custom["groups"].([]any); ok {
|
||||
for _, item := range g {
|
||||
if s, ok := item.(string); ok {
|
||||
c.Groups = append(c.Groups, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) (string, bool) {
|
||||
h := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
@@ -396,36 +158,12 @@ func unauthorized(w http.ResponseWriter, reason string) {
|
||||
})
|
||||
}
|
||||
|
||||
// DeriveHS256Key turns a configured secret of any length into a fixed 32-byte
|
||||
// HMAC key. HS256 requires >= 32 bytes; hashing guarantees that (and keeps the
|
||||
// minting and verifying sides in lockstep) so a short CDROP_HS256_SECRET can't
|
||||
// make shortcut-token signing fail. Both signing (httpapi) and verifying use it.
|
||||
func DeriveHS256Key(secret string) []byte {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// DeriveSessionTokenKey derives the HMAC key for cdrop's self-signed session
|
||||
// access tokens from CDROP_SESSION_SECRET. Domain-separated from both the at-rest
|
||||
// refresh-token AES key (plain sha256(secret), in httpapi) and the shortcut-token
|
||||
// key (DeriveHS256Key) so one secret yields three independent keys — a session
|
||||
// token can never validate as a shortcut token, or vice versa. Empty secret →
|
||||
// nil, which disables both minting and verifying (dev / unconfigured prod).
|
||||
func DeriveSessionTokenKey(secret string) []byte {
|
||||
if secret == "" {
|
||||
return nil
|
||||
}
|
||||
sum := sha256.Sum256([]byte("cdrop-session-jwt\x00" + secret))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// SanitizeDeviceName enforces the global ASCII-only device-name policy. Device
|
||||
// names ride in the X-Device-Name HTTP header, which can't carry non-ASCII
|
||||
// reliably (and browser fetch rejects such header values outright), so the name
|
||||
// is restricted to printable ASCII everywhere. Here we keep only printable ASCII
|
||||
// (0x20–0x7E), trim, and cap the length as a server-side backstop; clients also
|
||||
// validate the name up front for a clear error. Empty after sanitising → the
|
||||
// caller falls back to a UA-derived default.
|
||||
// SanitizeDeviceName enforces the global ASCII-only device-name policy. Device names
|
||||
// ride in the X-Device-Name HTTP header, which can't carry non-ASCII reliably (and
|
||||
// browser fetch rejects such header values outright), so the name is restricted to
|
||||
// printable ASCII everywhere. Here we keep only printable ASCII (0x20–0x7E), trim, and
|
||||
// cap the length as a server-side backstop; clients also validate up front. Empty after
|
||||
// sanitising → the caller falls back to a default.
|
||||
func SanitizeDeviceName(raw string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range raw {
|
||||
@@ -440,10 +178,9 @@ func SanitizeDeviceName(raw string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// normalizeDeviceType whitelists the client-declared X-Device-Type so a device
|
||||
// row only ever carries a known kind; anything unrecognised (incl. empty) falls
|
||||
// back to "browser", the default web client. Desktop clients send macos/windows;
|
||||
// "ios" is reserved for a future native iOS client (the Shortcut never sends it).
|
||||
// normalizeDeviceType whitelists the client-declared X-Device-Type so a device row
|
||||
// only ever carries a known kind; anything unrecognised (incl. empty) falls back to
|
||||
// "browser". Native clients send macos/windows/linux/ios.
|
||||
func normalizeDeviceType(raw string) string {
|
||||
switch t := strings.ToLower(strings.TrimSpace(raw)); t {
|
||||
case "macos", "windows", "linux", "ios", "browser":
|
||||
|
||||
Reference in New Issue
Block a user