Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions auth-service/bpvalidator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package main

import (
"context"
"fmt"
"net/http"

"github.com/go-resty/resty/v2"

"github.com/hmchangw/chat/pkg/errcode"
"github.com/hmchangw/chat/pkg/natsutil"
"github.com/hmchangw/chat/pkg/principal"
)

// httpBotplatformValidator is the production BotplatformValidator: it POSTs
// the supplied authToken to {baseURL}/v1/auth/validate and unmarshals the
// {valid, principal} response. Resty handles timeout, TLS, and redirects;
// the Resty client is supplied externally (so main wires the shared
// restyutil instance).
type httpBotplatformValidator struct {
client *resty.Client
baseURL string
}

// newHTTPBotplatformValidator returns a validator that talks to the
// botplatform-service at baseURL. baseURL should be the public botplatform
// URL of the LOCAL site (validation is local-DB only — cross-site routing is
// the gateway's job).
func newHTTPBotplatformValidator(client *resty.Client, baseURL string) *httpBotplatformValidator {
return &httpBotplatformValidator{client: client, baseURL: baseURL}
}

// Validate POSTs to botplatform /v1/auth/validate. Returns:
// - a typed errcode.Unauthenticated on a 401 from upstream (caller will
// surface it to the client as 401 invalid_token).
// - a raw wrapped error on network failures or non-2xx/401 statuses
// (caller will surface it as 503 upstream_unavailable).
func (v *httpBotplatformValidator) Validate(ctx context.Context, authToken string) (principal.Principal, error) {
var body struct {
Valid bool `json:"valid"`
Principal principal.Principal `json:"principal"`
}
req := v.client.R().
SetContext(ctx).
SetBody(map[string]string{"authToken": authToken}).
SetResult(&body)
if id := natsutil.RequestIDFromContext(ctx); id != "" {
req = req.SetHeader(natsutil.RequestIDHeader, id)
}
resp, err := req.Post(v.baseURL + "/v1/auth/validate")
if err != nil {
return principal.Principal{}, fmt.Errorf("validate authToken: %w", err)
}
switch resp.StatusCode() {
case http.StatusOK:
if !body.Valid {
return principal.Principal{}, errcode.Unauthenticated("session token invalid",
errcode.WithReason(errcode.BotplatformInvalidToken))
}
return body.Principal, nil
case http.StatusUnauthorized:
return principal.Principal{}, errcode.Unauthenticated("session token invalid",
errcode.WithReason(errcode.BotplatformInvalidToken))
default:
return principal.Principal{}, fmt.Errorf("botplatform validate: HTTP %d: %s",
resp.StatusCode(), string(resp.Body()))
}
}
167 changes: 125 additions & 42 deletions auth-service/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/hmchangw/chat/pkg/errcode"
"github.com/hmchangw/chat/pkg/errcode/errhttp"
pkgoidc "github.com/hmchangw/chat/pkg/oidc"
"github.com/hmchangw/chat/pkg/principal"
"github.com/hmchangw/chat/pkg/subject"
)

Expand All @@ -27,7 +28,13 @@ type TokenValidator interface {
}

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

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

// AuthHandler processes auth requests, validates SSO tokens via OIDC,
// and returns signed NATS user JWTs with scoped permissions.
// BotplatformValidator validates a session authToken against botplatform-
// service. Returned err is errcode.Unauthenticated for invalid tokens and a
// raw wrapped error for upstream failures (the handler surfaces those as
// 503 upstream_unavailable). The Principal type is the shared wire shape
// from pkg/principal, also produced by botplatform-service.
type BotplatformValidator interface {
Validate(ctx context.Context, authToken string) (principal.Principal, error)
}

// AuthHandler processes auth requests, validates SSO tokens via OIDC or
// session tokens via botplatform, and returns signed NATS user JWTs with
// role-scoped permissions.
type AuthHandler struct {
validator TokenValidator
signingKey nkeys.KeyPair
jwtExpiry time.Duration
jwtJitter float64 // fraction of jwtExpiry; 0 = fixed lifetime
randFloat func() float64 // injectable [0,1) source; defaults to crypto rand
devMode bool
validator TokenValidator
bpValidator BotplatformValidator // optional; nil disables the session-token branch
signingKey nkeys.KeyPair
jwtExpiry time.Duration
jwtJitter float64 // fraction of jwtExpiry; 0 = fixed lifetime
randFloat func() float64 // injectable [0,1) source; defaults to crypto rand
devMode bool
}

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

// WithBotplatformValidator enables the session-token branch of POST /auth.
// Without it, a request carrying an authToken is rejected as if the field
// were unsupported.
func WithBotplatformValidator(v BotplatformValidator) Option {
return func(h *AuthHandler) { h.bpValidator = v }
}

// NewAuthHandler creates an AuthHandler with the given token validator,
// NATS account signing key, and JWT expiry duration.
func NewAuthHandler(validator TokenValidator, signingKey nkeys.KeyPair, jwtExpiry time.Duration, devMode bool, opts ...Option) *AuthHandler {
Expand Down Expand Up @@ -112,8 +137,13 @@ func cryptoRandFloat() float64 {
return float64(n.Int64()) / float64(denom)
}

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

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

switch {
case req.SSOToken != "" && req.AuthToken != "":
errhttp.Write(ctx, c, errcode.BadRequest("set exactly one of ssoToken / authToken",
errcode.WithReason(errcode.BotplatformAmbiguousToken)))
return
case req.SSOToken == "" && req.AuthToken == "":
errhttp.Write(ctx, c, errcode.BadRequest("set exactly one of ssoToken / authToken",
errcode.WithReason(errcode.BotplatformMissingToken)))
return
case req.AuthToken != "":
h.handleSession(ctx, c, req)
return
}

h.handleSSO(ctx, c, req)
}

// handleSSO runs the existing OIDC validation + JWT mint. Behavior unchanged
// from the pre-extension code path.
func (h *AuthHandler) handleSSO(ctx context.Context, c *gin.Context, req authRequest) {
claims, err := h.validator.Validate(ctx, req.SSOToken)
if err != nil {
if errors.Is(err, pkgoidc.ErrTokenExpired) {
errhttp.Write(ctx, c, errcode.Unauthenticated("SSO token has expired, please re-login",
errcode.WithReason(errcode.AuthTokenExpired)))
return
}
// Non-expiry failures surface as "invalid SSO token"; attach the raw
// cause so the server log carries the actual reason.
errhttp.Write(ctx, c, errcode.Unauthenticated("invalid SSO token",
errcode.WithReason(errcode.AuthInvalidToken),
errcode.WithCause(err)))
Expand All @@ -152,7 +200,6 @@ func (h *AuthHandler) HandleAuth(c *gin.Context) {

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

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

slog.Debug("auth success", "account", account, "subject", claims.Subject)

// Parse description field: "employeeId, engName, chineseName"
employeeID, engName, chineseName := parseDescription(claims.Description)

c.JSON(http.StatusOK, authResponse{
NATSJWT: natsJWT,
UserInfo: userInfoResp{
Expand All @@ -188,6 +233,54 @@ func (h *AuthHandler) HandleAuth(c *gin.Context) {
})
}

// handleSession exchanges a botplatform session authToken for a NATS JWT. The
// scope is derived from the principal's roles (admin > bot > user) so a bot
// gets chat.bot.{stripped}.>, an admin gets chat.>, and the rare role-less
// session falls back to chat.user.{account}.>.
func (h *AuthHandler) handleSession(ctx context.Context, c *gin.Context, req authRequest) {
if h.bpValidator == nil {
errhttp.Write(ctx, c, errcode.Unavailable("session-token auth not configured",
errcode.WithReason(errcode.BotplatformUpstreamUnavailable)))
return
}

p, err := h.bpValidator.Validate(ctx, req.AuthToken)
if err != nil {
var ec *errcode.Error
if errors.As(err, &ec) {
errhttp.Write(ctx, c, ec)
return
}
errhttp.Write(ctx, c, errcode.Unavailable("botplatform unavailable",
errcode.WithReason(errcode.BotplatformUpstreamUnavailable),
errcode.WithCause(err)))
return
}
if p.Account == "" {
errhttp.Write(ctx, c, errcode.Unauthenticated("principal missing account",
errcode.WithReason(errcode.AuthInvalidToken)))
return
}
ctx = errcode.WithLogValues(ctx, "account", p.Account)

scope := principal.NATSSubjectScope(p.Account, p.Roles)
natsJWT, err := h.signNATSJWT(req.NATSPublicKey, scope)
if err != nil {
errhttp.Write(ctx, c, fmt.Errorf("generating NATS token: %w", err))
return
}

slog.Debug("session auth success", "account", p.Account, "roles", p.Roles)
c.JSON(http.StatusOK, authResponse{
NATSJWT: natsJWT,
UserInfo: userInfoResp{
Account: p.Account,
// No employee fields for bot/admin sessions — botplatform's
// principal carries identity, not directory metadata.
},
})
}

// handleDevAuth handles auth in dev mode: accepts account name directly
// without OIDC validation, for use during local development only.
func (h *AuthHandler) handleDevAuth(c *gin.Context) {
Expand All @@ -212,7 +305,8 @@ func (h *AuthHandler) handleDevAuth(c *gin.Context) {
}
ctx = errcode.WithLogValues(ctx, "account", req.Account)

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

// signNATSJWT creates a signed NATS user JWT with permissions scoped
// to the user's namespace and standard chat subjects.
func (h *AuthHandler) signNATSJWT(userPubKey, account string) (string, error) {
// signNATSJWT creates a signed NATS user JWT with the supplied pub/sub
// allowlist. The scope is computed upstream by pkg/principal.NATSSubjectScope
// so the role-to-subject mapping has a single source of truth across
// auth-service and future consumers.
func (h *AuthHandler) signNATSJWT(userPubKey string, scope principal.Scope) (string, error) {
uc := jwt.NewUserClaims(userPubKey)
uc.Expires = h.jwtExpiryAt().Unix()

// Publish permissions: user's own namespace + inbox for request-reply.
uc.Pub.Allow.Add(fmt.Sprintf("chat.user.%s.>", account))
uc.Pub.Allow.Add("_INBOX.>")

// Subscribe permissions: user's own namespace, all rooms, and inbox.
uc.Sub.Allow.Add(fmt.Sprintf("chat.user.%s.>", account))
uc.Sub.Allow.Add("chat.room.>")
uc.Sub.Allow.Add("_INBOX.>")

// Presence: read anyone's live state and publish batch queries. The state
// broadcast carries only the account (no siteID), so a single-token wildcard
// covers it. Writes (hello/ping/activity/bye/manual) live under the user's
// own chat.user.{account}.> namespace already granted above. Clients can read
// state but never publish it — the "state" vs "query" token keeps the query
// pub-rule from matching the state subject, so presence can't be forged.
uc.Sub.Allow.Add("chat.user.presence.state.*")
uc.Pub.Allow.Add("chat.user.presence.*.query.batch")

for _, s := range scope.PubAllow {
uc.Pub.Allow.Add(s)
}
for _, s := range scope.SubAllow {
uc.Sub.Allow.Add(s)
}
return uc.Encode(h.signingKey)
}

Expand Down
Loading
Loading