Skip to content

Commit 46a459e

Browse files
committed
feat(auth-service): session-token branch + role-derived NATS scope
POST /auth grows a session-token branch alongside the existing OIDC path. The server auto-routes by which token is present (no client-declared kind field): ssoToken set, authToken empty -> existing OIDC path, UNCHANGED. authToken set, ssoToken empty -> NEW: validate via botplatform, mint NATS JWT with role-derived scope. both set -> 400 ambiguous_token. neither set -> 400 missing_token. The session-token branch validates the supplied authToken against LOCAL botplatform /v1/auth/validate (cross-site routing is the gateway's job, not auth-service's). On success it derives the NATS JWT scope from the returned principal's roles via pkg/principal.NATSSubjectScope: admin -> chat.> (god-mode) bot -> chat.bot.{stripped account}.> user -> chat.user.{account}.> Admin dominates bot dominates user on multi-role principals. Internal refactor: signNATSJWT now takes a principal.Scope (pub/sub allowlists) and iterates them mechanically. All role-to-subject mapping lives in pkg/principal — auth-service has no role-specific branches in the JWT-signing path. The legacy hardcoded chat.user.> + chat.room.> + presence allowlist is recovered by NATSSubjectScope on the user branch, so the SSO path mints byte-identical claims to before. New httpBotplatformValidator implements BotplatformValidator using Resty: 5s timeout, propagates inbound X-Request-ID, surfaces 401 as errcode.Unauthenticated (caller passes through verbatim) and network failures as wrapped errors that the handler surfaces as 503 upstream_unavailable. Returns the shared pkg/principal.Principal DTO so no service-specific wire type is duplicated. main.go gains BOTPLATFORM_URL env var; when set, the validator is wired and the session-token branch is enabled. When unset, session-token requests fail with 503 upstream_unavailable. TDD coverage adds: session-token bot/admin happy paths with the correct subject allowlists baked into the JWT; multi-role admin-trumps-bot; invalid token -> 401 invalid_token; upstream unavailable -> 503; missing validator -> 503; ambiguous token -> 400 ambiguous_token; missing token -> 400 missing_token; SSO path regression check that botplatform is NEVER called when ssoToken is set even with a validator configured. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VrbwirKCHJ5Qz7xkYYfzxL
1 parent f91965e commit 46a459e

4 files changed

Lines changed: 438 additions & 42 deletions

File tree

auth-service/bpvalidator.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
8+
"github.com/go-resty/resty/v2"
9+
10+
"github.com/hmchangw/chat/pkg/errcode"
11+
"github.com/hmchangw/chat/pkg/natsutil"
12+
"github.com/hmchangw/chat/pkg/principal"
13+
)
14+
15+
// httpBotplatformValidator is the production BotplatformValidator: it POSTs
16+
// the supplied authToken to {baseURL}/v1/auth/validate and unmarshals the
17+
// {valid, principal} response. Resty handles timeout, TLS, and redirects;
18+
// the Resty client is supplied externally (so main wires the shared
19+
// restyutil instance).
20+
type httpBotplatformValidator struct {
21+
client *resty.Client
22+
baseURL string
23+
}
24+
25+
// newHTTPBotplatformValidator returns a validator that talks to the
26+
// botplatform-service at baseURL. baseURL should be the public botplatform
27+
// URL of the LOCAL site (validation is local-DB only — cross-site routing is
28+
// the gateway's job).
29+
func newHTTPBotplatformValidator(client *resty.Client, baseURL string) *httpBotplatformValidator {
30+
return &httpBotplatformValidator{client: client, baseURL: baseURL}
31+
}
32+
33+
// Validate POSTs to botplatform /v1/auth/validate. Returns:
34+
// - a typed errcode.Unauthenticated on a 401 from upstream (caller will
35+
// surface it to the client as 401 invalid_token).
36+
// - a raw wrapped error on network failures or non-2xx/401 statuses
37+
// (caller will surface it as 503 upstream_unavailable).
38+
func (v *httpBotplatformValidator) Validate(ctx context.Context, authToken string) (principal.Principal, error) {
39+
var body struct {
40+
Valid bool `json:"valid"`
41+
Principal principal.Principal `json:"principal"`
42+
}
43+
req := v.client.R().
44+
SetContext(ctx).
45+
SetBody(map[string]string{"authToken": authToken}).
46+
SetResult(&body)
47+
if id := natsutil.RequestIDFromContext(ctx); id != "" {
48+
req = req.SetHeader(natsutil.RequestIDHeader, id)
49+
}
50+
resp, err := req.Post(v.baseURL + "/v1/auth/validate")
51+
if err != nil {
52+
return principal.Principal{}, fmt.Errorf("validate authToken: %w", err)
53+
}
54+
switch resp.StatusCode() {
55+
case http.StatusOK:
56+
if !body.Valid {
57+
return principal.Principal{}, errcode.Unauthenticated("session token invalid",
58+
errcode.WithReason(errcode.BotplatformInvalidToken))
59+
}
60+
return body.Principal, nil
61+
case http.StatusUnauthorized:
62+
return principal.Principal{}, errcode.Unauthenticated("session token invalid",
63+
errcode.WithReason(errcode.BotplatformInvalidToken))
64+
default:
65+
return principal.Principal{}, fmt.Errorf("botplatform validate: HTTP %d: %s",
66+
resp.StatusCode(), string(resp.Body()))
67+
}
68+
}

auth-service/handler.go

Lines changed: 125 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/hmchangw/chat/pkg/errcode"
1919
"github.com/hmchangw/chat/pkg/errcode/errhttp"
2020
pkgoidc "github.com/hmchangw/chat/pkg/oidc"
21+
"github.com/hmchangw/chat/pkg/principal"
2122
"github.com/hmchangw/chat/pkg/subject"
2223
)
2324

@@ -27,7 +28,13 @@ type TokenValidator interface {
2728
}
2829

2930
type authRequest struct {
30-
SSOToken string `json:"ssoToken" binding:"required"`
31+
// Exactly one of SSOToken / AuthToken must be set. The server routes on
32+
// which field is present:
33+
// - SSOToken populated -> existing OIDC path (unchanged behavior).
34+
// - AuthToken populated -> new botplatform-validate path
35+
// (bots/admins exchange their session for a NATS JWT).
36+
SSOToken string `json:"ssoToken"`
37+
AuthToken string `json:"authToken"`
3138
NATSPublicKey string `json:"natsPublicKey" binding:"required"`
3239
}
3340

@@ -51,15 +58,26 @@ type userInfoResp struct {
5158
DeptID string `json:"deptId"`
5259
}
5360

54-
// AuthHandler processes auth requests, validates SSO tokens via OIDC,
55-
// and returns signed NATS user JWTs with scoped permissions.
61+
// BotplatformValidator validates a session authToken against botplatform-
62+
// service. Returned err is errcode.Unauthenticated for invalid tokens and a
63+
// raw wrapped error for upstream failures (the handler surfaces those as
64+
// 503 upstream_unavailable). The Principal type is the shared wire shape
65+
// from pkg/principal, also produced by botplatform-service.
66+
type BotplatformValidator interface {
67+
Validate(ctx context.Context, authToken string) (principal.Principal, error)
68+
}
69+
70+
// AuthHandler processes auth requests, validates SSO tokens via OIDC or
71+
// session tokens via botplatform, and returns signed NATS user JWTs with
72+
// role-scoped permissions.
5673
type AuthHandler struct {
57-
validator TokenValidator
58-
signingKey nkeys.KeyPair
59-
jwtExpiry time.Duration
60-
jwtJitter float64 // fraction of jwtExpiry; 0 = fixed lifetime
61-
randFloat func() float64 // injectable [0,1) source; defaults to crypto rand
62-
devMode bool
74+
validator TokenValidator
75+
bpValidator BotplatformValidator // optional; nil disables the session-token branch
76+
signingKey nkeys.KeyPair
77+
jwtExpiry time.Duration
78+
jwtJitter float64 // fraction of jwtExpiry; 0 = fixed lifetime
79+
randFloat func() float64 // injectable [0,1) source; defaults to crypto rand
80+
devMode bool
6381
}
6482

6583
// Option configures optional AuthHandler behavior.
@@ -84,6 +102,13 @@ func WithRandFloat(fn func() float64) Option {
84102
return func(h *AuthHandler) { h.randFloat = fn }
85103
}
86104

105+
// WithBotplatformValidator enables the session-token branch of POST /auth.
106+
// Without it, a request carrying an authToken is rejected as if the field
107+
// were unsupported.
108+
func WithBotplatformValidator(v BotplatformValidator) Option {
109+
return func(h *AuthHandler) { h.bpValidator = v }
110+
}
111+
87112
// NewAuthHandler creates an AuthHandler with the given token validator,
88113
// NATS account signing key, and JWT expiry duration.
89114
func NewAuthHandler(validator TokenValidator, signingKey nkeys.KeyPair, jwtExpiry time.Duration, devMode bool, opts ...Option) *AuthHandler {
@@ -112,8 +137,13 @@ func cryptoRandFloat() float64 {
112137
return float64(n.Int64()) / float64(denom)
113138
}
114139

115-
// HandleAuth validates the SSO token, resolves permissions based on
116-
// the user account, and returns a signed NATS JWT.
140+
// HandleAuth routes by which token is present:
141+
// - ssoToken set, authToken empty → existing OIDC path (unchanged).
142+
// - authToken set, ssoToken empty → new session-validate path via
143+
// botplatform. NATS JWT scope is derived from the principal's roles
144+
// (admin > bot > user) by pkg/principal.NATSSubjectScope.
145+
// - both set → 400 ambiguous_token (the client must pick one auth method).
146+
// - neither set → 400 missing_token.
117147
func (h *AuthHandler) HandleAuth(c *gin.Context) {
118148
if h.devMode {
119149
h.handleDevAuth(c)
@@ -124,7 +154,7 @@ func (h *AuthHandler) HandleAuth(c *gin.Context) {
124154

125155
var req authRequest
126156
if err := c.ShouldBindJSON(&req); err != nil {
127-
errhttp.Write(ctx, c, errcode.BadRequest("ssoToken and natsPublicKey are required",
157+
errhttp.Write(ctx, c, errcode.BadRequest("natsPublicKey is required",
128158
errcode.WithReason(errcode.AuthMissingFields)))
129159
return
130160
}
@@ -135,15 +165,33 @@ func (h *AuthHandler) HandleAuth(c *gin.Context) {
135165
return
136166
}
137167

168+
switch {
169+
case req.SSOToken != "" && req.AuthToken != "":
170+
errhttp.Write(ctx, c, errcode.BadRequest("set exactly one of ssoToken / authToken",
171+
errcode.WithReason(errcode.BotplatformAmbiguousToken)))
172+
return
173+
case req.SSOToken == "" && req.AuthToken == "":
174+
errhttp.Write(ctx, c, errcode.BadRequest("set exactly one of ssoToken / authToken",
175+
errcode.WithReason(errcode.BotplatformMissingToken)))
176+
return
177+
case req.AuthToken != "":
178+
h.handleSession(ctx, c, req)
179+
return
180+
}
181+
182+
h.handleSSO(ctx, c, req)
183+
}
184+
185+
// handleSSO runs the existing OIDC validation + JWT mint. Behavior unchanged
186+
// from the pre-extension code path.
187+
func (h *AuthHandler) handleSSO(ctx context.Context, c *gin.Context, req authRequest) {
138188
claims, err := h.validator.Validate(ctx, req.SSOToken)
139189
if err != nil {
140190
if errors.Is(err, pkgoidc.ErrTokenExpired) {
141191
errhttp.Write(ctx, c, errcode.Unauthenticated("SSO token has expired, please re-login",
142192
errcode.WithReason(errcode.AuthTokenExpired)))
143193
return
144194
}
145-
// Non-expiry failures surface as "invalid SSO token"; attach the raw
146-
// cause so the server log carries the actual reason.
147195
errhttp.Write(ctx, c, errcode.Unauthenticated("invalid SSO token",
148196
errcode.WithReason(errcode.AuthInvalidToken),
149197
errcode.WithCause(err)))
@@ -152,7 +200,6 @@ func (h *AuthHandler) HandleAuth(c *gin.Context) {
152200

153201
account := claims.Account()
154202
if account == "" {
155-
// Blank account would mint a JWT with chat.user..> permissions — refuse.
156203
errhttp.Write(ctx, c, errcode.Unauthenticated("token missing account claim",
157204
errcode.WithReason(errcode.AuthInvalidToken)))
158205
return
@@ -163,17 +210,15 @@ func (h *AuthHandler) HandleAuth(c *gin.Context) {
163210
}
164211
ctx = errcode.WithLogValues(ctx, "account", account)
165212

166-
natsJWT, err := h.signNATSJWT(req.NATSPublicKey, account)
213+
scope := principal.NATSSubjectScope(account, nil) // SSO users have no roles -> user scope
214+
natsJWT, err := h.signNATSJWT(req.NATSPublicKey, scope)
167215
if err != nil {
168216
errhttp.Write(ctx, c, fmt.Errorf("generating NATS token: %w", err))
169217
return
170218
}
171219

172220
slog.Debug("auth success", "account", account, "subject", claims.Subject)
173-
174-
// Parse description field: "employeeId, engName, chineseName"
175221
employeeID, engName, chineseName := parseDescription(claims.Description)
176-
177222
c.JSON(http.StatusOK, authResponse{
178223
NATSJWT: natsJWT,
179224
UserInfo: userInfoResp{
@@ -188,6 +233,54 @@ func (h *AuthHandler) HandleAuth(c *gin.Context) {
188233
})
189234
}
190235

236+
// handleSession exchanges a botplatform session authToken for a NATS JWT. The
237+
// scope is derived from the principal's roles (admin > bot > user) so a bot
238+
// gets chat.bot.{stripped}.>, an admin gets chat.>, and the rare role-less
239+
// session falls back to chat.user.{account}.>.
240+
func (h *AuthHandler) handleSession(ctx context.Context, c *gin.Context, req authRequest) {
241+
if h.bpValidator == nil {
242+
errhttp.Write(ctx, c, errcode.Unavailable("session-token auth not configured",
243+
errcode.WithReason(errcode.BotplatformUpstreamUnavailable)))
244+
return
245+
}
246+
247+
p, err := h.bpValidator.Validate(ctx, req.AuthToken)
248+
if err != nil {
249+
var ec *errcode.Error
250+
if errors.As(err, &ec) {
251+
errhttp.Write(ctx, c, ec)
252+
return
253+
}
254+
errhttp.Write(ctx, c, errcode.Unavailable("botplatform unavailable",
255+
errcode.WithReason(errcode.BotplatformUpstreamUnavailable),
256+
errcode.WithCause(err)))
257+
return
258+
}
259+
if p.Account == "" {
260+
errhttp.Write(ctx, c, errcode.Unauthenticated("principal missing account",
261+
errcode.WithReason(errcode.AuthInvalidToken)))
262+
return
263+
}
264+
ctx = errcode.WithLogValues(ctx, "account", p.Account)
265+
266+
scope := principal.NATSSubjectScope(p.Account, p.Roles)
267+
natsJWT, err := h.signNATSJWT(req.NATSPublicKey, scope)
268+
if err != nil {
269+
errhttp.Write(ctx, c, fmt.Errorf("generating NATS token: %w", err))
270+
return
271+
}
272+
273+
slog.Debug("session auth success", "account", p.Account, "roles", p.Roles)
274+
c.JSON(http.StatusOK, authResponse{
275+
NATSJWT: natsJWT,
276+
UserInfo: userInfoResp{
277+
Account: p.Account,
278+
// No employee fields for bot/admin sessions — botplatform's
279+
// principal carries identity, not directory metadata.
280+
},
281+
})
282+
}
283+
191284
// handleDevAuth handles auth in dev mode: accepts account name directly
192285
// without OIDC validation, for use during local development only.
193286
func (h *AuthHandler) handleDevAuth(c *gin.Context) {
@@ -212,7 +305,8 @@ func (h *AuthHandler) handleDevAuth(c *gin.Context) {
212305
}
213306
ctx = errcode.WithLogValues(ctx, "account", req.Account)
214307

215-
natsJWT, err := h.signNATSJWT(req.NATSPublicKey, req.Account)
308+
scope := principal.NATSSubjectScope(req.Account, nil)
309+
natsJWT, err := h.signNATSJWT(req.NATSPublicKey, scope)
216310
if err != nil {
217311
errhttp.Write(ctx, c, fmt.Errorf("generating NATS token: %w", err))
218312
return
@@ -230,30 +324,19 @@ func (h *AuthHandler) handleDevAuth(c *gin.Context) {
230324
})
231325
}
232326

233-
// signNATSJWT creates a signed NATS user JWT with permissions scoped
234-
// to the user's namespace and standard chat subjects.
235-
func (h *AuthHandler) signNATSJWT(userPubKey, account string) (string, error) {
327+
// signNATSJWT creates a signed NATS user JWT with the supplied pub/sub
328+
// allowlist. The scope is computed upstream by pkg/principal.NATSSubjectScope
329+
// so the role-to-subject mapping has a single source of truth across
330+
// auth-service and future consumers.
331+
func (h *AuthHandler) signNATSJWT(userPubKey string, scope principal.Scope) (string, error) {
236332
uc := jwt.NewUserClaims(userPubKey)
237333
uc.Expires = h.jwtExpiryAt().Unix()
238-
239-
// Publish permissions: user's own namespace + inbox for request-reply.
240-
uc.Pub.Allow.Add(fmt.Sprintf("chat.user.%s.>", account))
241-
uc.Pub.Allow.Add("_INBOX.>")
242-
243-
// Subscribe permissions: user's own namespace, all rooms, and inbox.
244-
uc.Sub.Allow.Add(fmt.Sprintf("chat.user.%s.>", account))
245-
uc.Sub.Allow.Add("chat.room.>")
246-
uc.Sub.Allow.Add("_INBOX.>")
247-
248-
// Presence: read anyone's live state and publish batch queries. The state
249-
// broadcast carries only the account (no siteID), so a single-token wildcard
250-
// covers it. Writes (hello/ping/activity/bye/manual) live under the user's
251-
// own chat.user.{account}.> namespace already granted above. Clients can read
252-
// state but never publish it — the "state" vs "query" token keeps the query
253-
// pub-rule from matching the state subject, so presence can't be forged.
254-
uc.Sub.Allow.Add("chat.user.presence.state.*")
255-
uc.Pub.Allow.Add("chat.user.presence.*.query.batch")
256-
334+
for _, s := range scope.PubAllow {
335+
uc.Pub.Allow.Add(s)
336+
}
337+
for _, s := range scope.SubAllow {
338+
uc.Sub.Allow.Add(s)
339+
}
257340
return uc.Encode(h.signingKey)
258341
}
259342

0 commit comments

Comments
 (0)