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
5 changes: 3 additions & 2 deletions auth-service/deploy/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ PORT=8080
NATS_JWT_EXPIRY=2h

# === NATS ===
# Account private seed (starts with SA) — signs NATS user JWTs
AUTH_SIGNING_KEY=SAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# Account scoped signing key seed (starts with SA). Signs user JWTs; the scope
# template on this key supplies per-user permissions via the account tag.
AUTH_SCOPED_SIGNING_KEY=SAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

# === OIDC / Keycloak ===
# Uses Docker service name (auth-service runs inside Docker network).
Expand Down
26 changes: 5 additions & 21 deletions auth-service/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,30 +230,14 @@ 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.
// signNATSJWT signs a scoped NATS user JWT. Permissions and limits come
// from the account's scoped signing key template; the account tag drives
// per-user subject substitution ({{tag(account)}}).
func (h *AuthHandler) signNATSJWT(userPubKey, account string) (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")

uc.Tags.Add("account:" + account)
uc.SetScoped(true)
return uc.Encode(h.signingKey)
}

Expand Down
34 changes: 11 additions & 23 deletions auth-service/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,21 +124,13 @@ func TestHandleAuth_ValidToken(t *testing.T) {
expiresAt := time.Unix(claims.Expires, 0)
assert.LessOrEqual(t, time.Until(expiresAt), 2*time.Hour+time.Minute)

// Check publish permissions: chat.user.alice.> and _INBOX.>
assert.Contains(t, []string(claims.Pub.Allow), "chat.user.alice.>")
assert.Contains(t, []string(claims.Pub.Allow), "_INBOX.>")

// Check subscribe permissions: chat.user.alice.>, chat.room.>, _INBOX.>
assert.Contains(t, []string(claims.Sub.Allow), "chat.user.alice.>")
assert.Contains(t, []string(claims.Sub.Allow), "chat.room.>")
assert.Contains(t, []string(claims.Sub.Allow), "_INBOX.>")

// Presence: read anyone's live state; publish batch queries (but not state).
// Scoped to exactly the implemented subjects — no deeper subtopics.
assert.Contains(t, []string(claims.Sub.Allow), "chat.user.presence.state.*")
assert.Contains(t, []string(claims.Pub.Allow), "chat.user.presence.*.query.batch")
assert.NotContains(t, []string(claims.Pub.Allow), "chat.user.presence.state.*",
"clients must not be able to publish presence state")
// Perms and limits live on the scoped signing key template; the JWT
// only stamps the account tag for {{tag(account)}} substitution.
assert.Contains(t, claims.Tags, "account:alice")
assert.Empty(t, claims.Pub.Allow)
assert.Empty(t, claims.Sub.Allow)
assert.Equal(t, jwt.UserPermissionLimits{}, claims.UserPermissionLimits,
"non-zero per-user limits trigger auth violation under a scoped SK")
}

func TestHandleAuth_ExpiredToken(t *testing.T) {
Expand Down Expand Up @@ -245,11 +237,9 @@ func TestHandleAuth_PermissionsPerUser(t *testing.T) {
claims, err := jwt.DecodeUserClaims(resp.NATSJWT)
require.NoError(t, err)

wantPub := "chat.user." + account + ".>"
assert.Contains(t, []string(claims.Pub.Allow), wantPub)

wantSub := "chat.user." + account + ".>"
assert.Contains(t, []string(claims.Sub.Allow), wantSub)
assert.Contains(t, claims.Tags, "account:"+account)
assert.Empty(t, claims.Pub.Allow)
assert.Empty(t, claims.Sub.Allow)
})
}
}
Expand All @@ -276,12 +266,10 @@ func TestHandleAuth_DevMode_ValidRequest(t *testing.T) {
assert.Equal(t, "alice", resp.UserInfo.EngName)
assert.Equal(t, "alice@dev.local", resp.UserInfo.Email)

// Verify NATS JWT is valid and scoped to alice.
claims, err := jwt.DecodeUserClaims(resp.NATSJWT)
require.NoError(t, err)
assert.Equal(t, userPub, claims.Subject)
assert.Contains(t, []string(claims.Pub.Allow), "chat.user.alice.>")
assert.Contains(t, []string(claims.Sub.Allow), "chat.user.alice.>")
assert.Contains(t, claims.Tags, "account:alice")
}

func TestHandleAuth_DevMode_MissingAccount(t *testing.T) {
Expand Down
9 changes: 6 additions & 3 deletions auth-service/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,12 @@ func TestAuthHandler_Integration(t *testing.T) {
claims, err := jwt.DecodeUserClaims(resp.NATSJWT)
require.NoError(t, err)

// Verify publish permissions contain user namespace.
assert.Contains(t, []string(claims.Pub.Allow), "chat.user.testuser.>")
assert.Contains(t, []string(claims.Sub.Allow), "chat.room.>")
// Perms and limits live on the scoped signing key template; the JWT
// only stamps the account tag for {{tag(account)}} substitution.
assert.Contains(t, claims.Tags, "account:testuser")
assert.Empty(t, claims.Pub.Allow)
assert.Empty(t, claims.Sub.Allow)
assert.Equal(t, jwt.UserPermissionLimits{}, claims.UserPermissionLimits)
}

func TestMain(m *testing.M) { testutil.RunTests(m) }
12 changes: 6 additions & 6 deletions auth-service/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ import (
)

type config struct {
Port string `env:"PORT" envDefault:"8080"`
DevMode bool `env:"DEV_MODE" envDefault:"false"`
AuthSigningKey string `env:"AUTH_SIGNING_KEY,required"`
NATSJWTExpiry time.Duration `env:"NATS_JWT_EXPIRY" envDefault:"2h"`
NATSJWTExpiryJitter float64 `env:"NATS_JWT_EXPIRY_JITTER" envDefault:"0.1"`
Port string `env:"PORT" envDefault:"8080"`
DevMode bool `env:"DEV_MODE" envDefault:"false"`
AuthScopedSigningKey string `env:"AUTH_SCOPED_SIGNING_KEY,required"`
NATSJWTExpiry time.Duration `env:"NATS_JWT_EXPIRY" envDefault:"2h"`
NATSJWTExpiryJitter float64 `env:"NATS_JWT_EXPIRY_JITTER" envDefault:"0.1"`

// OIDC settings — required when DEV_MODE is false.
OIDCIssuerURL string `env:"OIDC_ISSUER_URL"`
Expand All @@ -45,7 +45,7 @@ func run() error {
return fmt.Errorf("parse config: %w", err)
}

signingKP, err := nkeys.FromSeed([]byte(cfg.AuthSigningKey))
signingKP, err := nkeys.FromSeed([]byte(cfg.AuthScopedSigningKey))
if err != nil {
return fmt.Errorf("parse signing key: %w", err)
}
Expand Down
43 changes: 32 additions & 11 deletions docker-local/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,42 @@ docker run --rm \
nsc add account --name chatapp 2>&1 | sed "s/^/ /"
nsc edit account chatapp --js-mem-storage 512M --js-disk-storage 5G --js-streams 10 2>&1 | sed "s/^/ /"
# Scoped signing key that auth-service uses to sign user JWTs. The role
# template mirrors the grants auth-service used to inline per JWT, keyed
# off the account:<account> tag every user JWT now carries.
nsc edit signing-key --account chatapp --sk generate --role scoped_user \
--allow-sub "chat.user.{{tag(account)}}.>" \
--allow-sub "chat.room.>" \
--allow-sub "_INBOX.>" \
--allow-sub "chat.user.presence.state.*" \
--allow-pub "chat.user.{{tag(account)}}.>" \
--allow-pub "_INBOX.>" \
--allow-pub "chat.user.presence.*.query.batch" \
--allow-pub-response \
> /output/sk_edit.log 2>&1
AUTH_SK_PUB=$(grep -Eo "A[A-Z0-9]{55}" /output/sk_edit.log | head -1)
if [ -z "$AUTH_SK_PUB" ]; then
echo "ERROR: failed to extract auth-service signing key pubkey"
cat /output/sk_edit.log
exit 1
fi
nsc describe operator --raw > /output/operator.jwt
nsc describe account chatapp --raw > /output/account.jwt
nsc describe account SYS --raw > /output/sys.jwt
nsc describe account chatapp 2>/dev/null | grep "Account ID" | awk -F"|" "{gsub(/[ \t]/, \"\", \$3); print \$3}" > /output/account_pub.txt
nsc describe account SYS 2>/dev/null | grep "Account ID" | awk -F"|" "{gsub(/[ \t]/, \"\", \$3); print \$3}" > /output/sys_pub.txt
ACCOUNT_PUB=$(cat /output/account_pub.txt)
SEED_FILE=$(find /root/.local/share/nats/nsc/keys -name "${ACCOUNT_PUB}.nk" 2>/dev/null | head -1)
if [ -z "$SEED_FILE" ]; then
SEED_FILE=$(find /nsc -name "${ACCOUNT_PUB}.nk" 2>/dev/null | head -1)
AUTH_SK_SEED_FILE=$(find /root/.local/share/nats/nsc/keys -name "${AUTH_SK_PUB}.nk" 2>/dev/null | head -1)
if [ -z "$AUTH_SK_SEED_FILE" ]; then
AUTH_SK_SEED_FILE=$(find /nsc -name "${AUTH_SK_PUB}.nk" 2>/dev/null | head -1)
fi
if [ -z "$SEED_FILE" ]; then
echo "ERROR: Could not find account seed for ${ACCOUNT_PUB}"
if [ -z "$AUTH_SK_SEED_FILE" ]; then
echo "ERROR: Could not find seed for signing key ${AUTH_SK_PUB}"
exit 1
fi
cat "$SEED_FILE" > /output/account_seed.txt
cat "$AUTH_SK_SEED_FILE" > /output/auth_sk_seed.txt
nsc add user --account chatapp --name backend
nsc edit user --account chatapp --name backend --allow-sub ">" --allow-pub ">"
Expand All @@ -79,12 +98,12 @@ ACCOUNT_JWT=$(cat "$TMPDIR/account.jwt")
SYS_JWT=$(cat "$TMPDIR/sys.jwt")
ACCOUNT_PUB_KEY=$(cat "$TMPDIR/account_pub.txt")
SYS_PUB_KEY=$(cat "$TMPDIR/sys_pub.txt")
ACCOUNT_SEED=$(cat "$TMPDIR/account_seed.txt")
AUTH_SK_SEED=$(cat "$TMPDIR/auth_sk_seed.txt")

echo ""
echo " Operator JWT: ${OPERATOR_JWT:0:50}..."
echo " Account Public Key: $ACCOUNT_PUB_KEY"
echo " Account Seed: <hidden — written to $ENV_FILE>"
echo " Auth SK Seed: <hidden — written to $ENV_FILE>"
echo " SYS Public Key: $SYS_PUB_KEY"
echo " Backend creds: $BACKEND_CREDS"
echo ""
Expand All @@ -93,9 +112,11 @@ cat > "$ENV_FILE" <<EOF
# Generated by docker-local/setup.sh — do not commit this file.
# Regenerate with: ./docker-local/setup.sh
# Auth-service signs user JWTs with the chatapp account nkey (the private seed).
# Auth-service signs user JWTs with the chatapp account's scoped signing key.
# The scope template supplies per-user permissions; auth-service stamps the
# account tag on each JWT so {{tag(account)}} resolves to the right subjects.
# All other microservices authenticate with backend.creds via NATS_CREDS_FILE.
AUTH_SIGNING_KEY=${ACCOUNT_SEED}
AUTH_SCOPED_SIGNING_KEY=${AUTH_SK_SEED}
# Shared NATS endpoint inside the chat-local docker network.
NATS_URL=nats://nats:4222
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/botplatform/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ drawio -x -f png -e -s 2 docs/specs/diagrams/<file>.drawio

Verified against the repo (`auth-service/`, `pkg/userstore`, `pkg/model`, `pkg/subject`):

- **`auth-service` is OIDC/SSO-only.** `POST /auth` (`auth-service/routes.go:5`) validates an SSO token (or a dev account name in dev mode), then signs a **NATS user JWT** with scoped pub/sub permissions using the account signing key (`AUTH_SIGNING_KEY`). See `auth-service/handler.go:234-249` for the grants (`chat.user.{account}.>`, `chat.room.>`, `_INBOX.>`).
- **`auth-service` is OIDC/SSO-only.** `POST /auth` (`auth-service/routes.go:5`) validates an SSO token (or a dev account name in dev mode), then signs a **NATS user JWT** using the account's scoped signing key (`AUTH_SCOPED_SIGNING_KEY`). Per-user permissions come from the scope template; the JWT only stamps the `account:<account>` tag so `{{tag(account)}}` in the template resolves to `chat.user.{account}.>`, `chat.room.>`, `_INBOX.>`, and presence subjects.
- **Clients talk to NATS directly** after `/auth`. There is no HTTP→NATS gateway in the repo; all RPC is NATS request/reply via `pkg/natsrouter`.
- **No password storage, no bcrypt, no session/login-token store exists anywhere.** Clean slate — no legacy auth code in the nextgen repo to refactor around.
- **Identity already works for bots.** `model.User` (`pkg/model/user.go`) carries `Account`, `SiteID`, `Roles`, display names; `model.IsBotAccount` (`pkg/model/account.go`) classifies bots by `*.bot` suffix / `p_` prefix; `pkg/userstore` resolves any account through a pod-local LRU+singleflight cache.
Expand Down
2 changes: 1 addition & 1 deletion docs/superpowers/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ All client publishes are under `chat.user.{account}.>`:

**Dependencies**: NATS
**Key Interface**: `TokenVerifier``Verify(token string) (account string, err error)`
**Config**: `NATS_URL`, `NATS_CREDS`, `AUTH_SIGNING_KEY` (required)
**Config**: `NATS_URL`, `NATS_CREDS`, `AUTH_SCOPED_SIGNING_KEY` (required)

### 7.2 Message Worker (`message-worker/`)

Expand Down
Loading