@@ -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
2930type 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.
5673type 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.
89114func 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.
117147func (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.
193286func (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