Skip to content
Merged
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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ HTTP API providing user/client message handling for an fmsg host. Exposes CRUD o
- [Environment Variables](#environment-variables)
- [Authentication](#authentication)
- [EdDSA (production, JWKS-backed JWTs)](#eddsa-production-jwks-backed-jwts)
- [Delegated OAuth tokens](#delegated-oauth-tokens)
- [API Keys And First-Party JWTs](#api-keys-and-first-party-jwts)
- [Building](#building)
- [Testing](#testing)
Expand All @@ -32,6 +33,7 @@ HTTP API providing user/client message handling for an fmsg host. Exposes CRUD o
| `FMSG_JWT_JWKS_URL` | *(prod)* | JWKS endpoint for the configured identity provider (e.g. `https://idp.example.com/.well-known/jwks.json`). When set, the API verifies EdDSA (Ed25519) JWTs. Public keys are fetched and cached, refreshed and looked up by the token's `kid` header. |
| `FMSG_JWT_ISSUER` | *(prod, required with JWKS)* | Expected `iss` claim value (e.g. `https://idp.example.com/`). Tokens with a different issuer are rejected. This must exactly match the token issuer. |
| `FMSG_JWT_AUDIENCE` | *(optional)* | When set, tokens must include this value in their `aud` claim. Leave unset if your identity provider does not issue an `aud` claim. |
| `FMSG_JWT_OAUTH_AUDIENCE` | *(optional)* | Enables delegated OAuth tokens: provider tokens whose `aud` contains this value are scope-restricted and cannot administer API keys or grants. Requires `FMSG_JWT_AUDIENCE`, set to a different value. See [Delegated OAuth tokens](#delegated-oauth-tokens). |
| `FMSG_JWT_ADDRESS_CLAIM` | *(prod, required with JWKS)* | JWT claim name containing the fmsg address in `@user@domain` form, e.g. `sub` or a namespaced custom claim. |
| `FMSG_API_TOKEN_ED25519_PRIVATE_KEY` | *(optional)* | Base64-encoded Ed25519 private key or seed used to mint first-party JWTs from API keys. Required to enable `/fmsg/token` and sub-account routes. |
| `FMSG_API_TOKEN_ISSUER` | `fmsg-webapi` | Issuer for first-party API-key JWTs. |
Expand Down Expand Up @@ -102,6 +104,31 @@ configured) and includes the configured address claim. Whether that token is
an ID token or access token is determined by the identity provider
configuration for the deployment.

### Delegated OAuth tokens

Active when `FMSG_JWT_OAUTH_AUDIENCE` is set. The identity provider can then
issue tokens to third-party clients (for example through an MCP server using
OAuth token exchange) that message as the consenting user without the
privileges of that user's own session.

The `aud` claim alone decides the kind of token. A token whose `aud` contains
`FMSG_JWT_AUDIENCE` is an owner session, as before. A token whose `aud`
contains `FMSG_JWT_OAUTH_AUDIENCE` is delegated: it needs the `fmsg:read` or
`fmsg:write` scope for each message, attachment and WebSocket route, and is
refused every other route, including all sub-account (API key and grant)
routes and push subscriptions. A missing or malformed `scope` is refused with
403; it never falls back to owner privileges. A token carrying both audiences,
or an owner-audience token carrying an `act` claim, is rejected.

`X-FMSG-Act-As` is refused for delegated tokens unless the address is listed in
the token's `fmsg_identities` claim and also passes the usual grant check.
Message permissions, quotas and acceptance checks are unchanged. With the
variable unset, behaviour is exactly as before and a token issued for the
OAuth audience fails the audience check.

[docs/oauth-claims.md](docs/oauth-claims.md) is the full claims contract for
issuers and resource servers.

### API Keys And First-Party JWTs

Active when `FMSG_API_TOKEN_ED25519_PRIVATE_KEY` is set. Programmatic clients
Expand Down
122 changes: 69 additions & 53 deletions cmd/fmsg-webapi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func main() {
jwksURL := os.Getenv("FMSG_JWT_JWKS_URL")
jwtIssuer := os.Getenv("FMSG_JWT_ISSUER")
jwtAudience := os.Getenv("FMSG_JWT_AUDIENCE")
jwtOAuthAudience := os.Getenv("FMSG_JWT_OAUTH_AUDIENCE")
jwtAddressClaim := os.Getenv("FMSG_JWT_ADDRESS_CLAIM")
apiTokenPrivate := os.Getenv("FMSG_API_TOKEN_ED25519_PRIVATE_KEY")
apiTokenIssuer := envOrDefault("FMSG_API_TOKEN_ISSUER", apiauth.DefaultTokenIssuer)
Expand Down Expand Up @@ -101,7 +102,7 @@ func main() {
}

// Initialise authentication middleware.
jwtCfg, err := buildJWTConfig(ctx, jwksURL, jwtIssuer, jwtAudience, jwtAddressClaim, idURL, tokenIssuer, apiStore)
jwtCfg, err := buildJWTConfig(ctx, jwksURL, jwtIssuer, jwtAudience, jwtOAuthAudience, jwtAddressClaim, idURL, tokenIssuer, apiStore)
if err != nil {
log.Fatalf("failed to configure auth: %v", err)
}
Expand Down Expand Up @@ -164,54 +165,13 @@ func main() {
go hub.Run(context.Background())
wsHandler := handlers.NewWSHandler(jwtVerifier, hub, corsOrigins)

var tokenHandler *handlers.TokenHandler
var subAccountHandler *handlers.SubAccountHandler
if tokenIssuer != nil {
tokenHandler := handlers.NewTokenHandler(apiStore, tokenIssuer, idURL)
router.POST("/fmsg/token", tokenHandler.Exchange)
tokenHandler = handlers.NewTokenHandler(apiStore, tokenIssuer, idURL)
subAccountHandler = handlers.NewSubAccountHandler(apiStore, idURL)
}

// Register routes under /fmsg, all protected by JWT.
fmsg := router.Group("/fmsg")
fmsg.Use(jwtMiddleware)
{
if tokenIssuer != nil {
subAccountHandler := handlers.NewSubAccountHandler(apiStore, idURL)
fmsg.GET("/sub-accounts", subAccountHandler.List)
fmsg.POST("/sub-accounts", subAccountHandler.Create)
fmsg.GET("/sub-accounts/:agent", subAccountHandler.Get)
fmsg.PATCH("/sub-accounts/:agent", subAccountHandler.UpdateCIDRs)
fmsg.POST("/sub-accounts/:agent/rotate-key", subAccountHandler.RotateKey)
fmsg.DELETE("/sub-accounts/:agent", subAccountHandler.Delete)
}

fmsg.GET("", msgHandler.List)
fmsg.GET("/sent", msgHandler.Sent)
fmsg.POST("", msgHandler.Atomic((*handlers.MessageHandler).Create))
fmsg.GET("/:id", msgHandler.Get)
fmsg.PUT("/:id", msgHandler.Atomic((*handlers.MessageHandler).Update))
fmsg.DELETE("/:id", msgHandler.Atomic((*handlers.MessageHandler).Delete))
fmsg.POST("/:id/send", msgHandler.Atomic((*handlers.MessageHandler).Send))
fmsg.POST("/:id/read", msgHandler.MarkRead)
fmsg.POST("/:id/add-to", msgHandler.Atomic((*handlers.MessageHandler).AddRecipients))
fmsg.POST("/:id/react", msgHandler.Atomic((*handlers.MessageHandler).React))
fmsg.GET("/:id/data", msgHandler.DownloadData)
fmsg.GET("/:id/thread", msgHandler.ThreadText)
fmsg.GET("/:id/thread/messages", msgHandler.ThreadMessages)

fmsg.POST("/:id/attach", attHandler.Atomic((*handlers.AttachmentHandler).Upload))
fmsg.GET("/:id/attach/:filename", attHandler.Download)
fmsg.DELETE("/:id/attach/:filename", attHandler.Atomic((*handlers.AttachmentHandler).DeleteAttachment))

if pushHandler != nil {
fmsg.POST("/push/subscribe", pushHandler.Subscribe)
fmsg.DELETE("/push/subscribe", pushHandler.Unsubscribe)
}
}

// The WebSocket endpoint is registered outside the JWT-protected group:
// browsers cannot set an Authorization header on a WebSocket, so the
// handler authenticates itself via the access_token query parameter or
// an Authorization header.
router.GET("/fmsg/ws", wsHandler.Connect)
registerRoutes(router, jwtMiddleware, msgHandler, attHandler, tokenHandler, subAccountHandler, pushHandler, wsHandler)

srv := &http.Server{
Handler: router,
Expand Down Expand Up @@ -295,25 +255,81 @@ func envOrDefaultDuration(key string, defaultValue time.Duration) time.Duration
return defaultValue
}

// registerRoutes registers every API route. Optional handlers may be nil.
// Routes that delegated OAuth tokens may call must also be listed in the
// middleware scope table; a test keeps the two in step.
func registerRoutes(router *gin.Engine, jwtMiddleware gin.HandlerFunc, msgHandler *handlers.MessageHandler, attHandler *handlers.AttachmentHandler, tokenHandler *handlers.TokenHandler, subAccountHandler *handlers.SubAccountHandler, pushHandler *handlers.PushHandler, wsHandler *handlers.WSHandler) {
if tokenHandler != nil {
router.POST("/fmsg/token", tokenHandler.Exchange)
}

// Register routes under /fmsg, all protected by JWT.
fmsg := router.Group("/fmsg")
fmsg.Use(jwtMiddleware)
{
if subAccountHandler != nil {
fmsg.GET("/sub-accounts", subAccountHandler.List)
fmsg.POST("/sub-accounts", subAccountHandler.Create)
fmsg.GET("/sub-accounts/:agent", subAccountHandler.Get)
fmsg.PATCH("/sub-accounts/:agent", subAccountHandler.UpdateCIDRs)
fmsg.POST("/sub-accounts/:agent/rotate-key", subAccountHandler.RotateKey)
fmsg.DELETE("/sub-accounts/:agent", subAccountHandler.Delete)
}

fmsg.GET("", msgHandler.List)
fmsg.GET("/sent", msgHandler.Sent)
fmsg.POST("", msgHandler.Atomic((*handlers.MessageHandler).Create))
fmsg.GET("/:id", msgHandler.Get)
fmsg.PUT("/:id", msgHandler.Atomic((*handlers.MessageHandler).Update))
fmsg.DELETE("/:id", msgHandler.Atomic((*handlers.MessageHandler).Delete))
fmsg.POST("/:id/send", msgHandler.Atomic((*handlers.MessageHandler).Send))
fmsg.POST("/:id/read", msgHandler.MarkRead)
fmsg.POST("/:id/add-to", msgHandler.Atomic((*handlers.MessageHandler).AddRecipients))
fmsg.POST("/:id/react", msgHandler.Atomic((*handlers.MessageHandler).React))
fmsg.GET("/:id/data", msgHandler.DownloadData)
fmsg.GET("/:id/thread", msgHandler.ThreadText)
fmsg.GET("/:id/thread/messages", msgHandler.ThreadMessages)

fmsg.POST("/:id/attach", attHandler.Atomic((*handlers.AttachmentHandler).Upload))
fmsg.GET("/:id/attach/:filename", attHandler.Download)
fmsg.DELETE("/:id/attach/:filename", attHandler.Atomic((*handlers.AttachmentHandler).DeleteAttachment))

if pushHandler != nil {
fmsg.POST("/push/subscribe", pushHandler.Subscribe)
fmsg.DELETE("/push/subscribe", pushHandler.Unsubscribe)
}
}

// The WebSocket endpoint is registered outside the JWT-protected group:
// browsers cannot set an Authorization header on a WebSocket, so the
// handler authenticates itself via the access_token query parameter or
// an Authorization header.
router.GET("/fmsg/ws", wsHandler.Connect)
}

// buildJWTConfig assembles a middleware.Config from environment-derived inputs.
func buildJWTConfig(ctx context.Context, jwksURL, issuer, audience, addressClaim, idURL string, tokenIssuer *apiauth.TokenIssuer, apiStore *apiauth.Store) (middleware.Config, error) {
func buildJWTConfig(ctx context.Context, jwksURL, issuer, audience, oauthAudience, addressClaim, idURL string, tokenIssuer *apiauth.TokenIssuer, apiStore *apiauth.Store) (middleware.Config, error) {
cfg := middleware.Config{
Issuer: issuer,
Audience: audience,
AddressClaim: addressClaim,
IDURL: idURL,
Issuer: issuer,
Audience: audience,
OAuthAudience: oauthAudience,
AddressClaim: addressClaim,
IDURL: idURL,
}

if jwksURL != "" {
if issuer == "" || addressClaim == "" {
return cfg, errors.New("FMSG_JWT_ISSUER and FMSG_JWT_ADDRESS_CLAIM are required when FMSG_JWT_JWKS_URL is set")
}
if oauthAudience != "" && (audience == "" || audience == oauthAudience) {
return cfg, errors.New("FMSG_JWT_OAUTH_AUDIENCE requires FMSG_JWT_AUDIENCE to be set to a different value")
}
k, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
if err != nil {
return cfg, err
}
cfg.JWKS = k.Keyfunc
log.Printf("EdDSA auth enabled (issuer=%s, jwks=%s, audience=%q, address_claim=%s)", issuer, jwksURL, audience, addressClaim)
log.Printf("EdDSA auth enabled (issuer=%s, jwks=%s, audience=%q, oauth_audience=%q, address_claim=%s)", issuer, jwksURL, audience, oauthAudience, addressClaim)
} else {
log.Println("EdDSA auth disabled (FMSG_JWT_JWKS_URL not set)")
}
Expand Down
57 changes: 57 additions & 0 deletions cmd/fmsg-webapi/routes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package main

import (
"testing"

"github.com/gin-gonic/gin"

"github.com/markmnl/fmsg-webapi/internal/handlers"
"github.com/markmnl/fmsg-webapi/internal/middleware"
)

// TestDelegatedOAuthRouteCoverage keeps the route table and the delegated
// OAuth scope table in step: every bearer-authenticated route is either open
// to delegated tokens under a scope or listed here as deliberately closed.
func TestDelegatedOAuthRouteCoverage(t *testing.T) {
closed := map[string]bool{
"GET /fmsg/sub-accounts": true,
"POST /fmsg/sub-accounts": true,
"GET /fmsg/sub-accounts/:agent": true,
"PATCH /fmsg/sub-accounts/:agent": true,
"POST /fmsg/sub-accounts/:agent/rotate-key": true,
"DELETE /fmsg/sub-accounts/:agent": true,
"POST /fmsg/push/subscribe": true,
"DELETE /fmsg/push/subscribe": true,
}
// POST /fmsg/token authenticates with an API key, not a bearer token.
unauthenticated := map[string]bool{"POST /fmsg/token": true}

gin.SetMode(gin.TestMode)
router := gin.New()
msgs := handlers.NewMessageHandler(nil, "", 0, 0, 0, nil, "", "")
registerRoutes(router, func(c *gin.Context) {}, msgs,
handlers.NewAttachmentHandler(nil, "", 0, 0),
handlers.NewTokenHandler(nil, nil, ""),
handlers.NewSubAccountHandler(nil, ""),
handlers.NewPushHandler(nil, msgs, "", "", "", ""),
handlers.NewWSHandler(nil, handlers.NewHub(msgs), nil))

seen := map[string]bool{}
for _, route := range router.Routes() {
key := route.Method + " " + route.Path
seen[key] = true
_, open := middleware.OAuthRouteScope(route.Method, route.Path)
switch {
case unauthenticated[key]:
case open && closed[key]:
t.Errorf("%s is both open to and closed to delegated OAuth tokens", key)
case !open && !closed[key]:
t.Errorf("%s is not classified for delegated OAuth tokens: add it to the scope table or to the closed list", key)
}
}
for key := range closed {
if !seen[key] {
t.Errorf("closed route %s is no longer registered", key)
}
}
}
79 changes: 79 additions & 0 deletions docs/oauth-claims.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Delegated OAuth tokens: claims contract

This is the contract between an identity provider (or authorization server)
that issues delegated tokens, a resource server such as an MCP server that
obtains them, and this API. It applies when `FMSG_JWT_OAUTH_AUDIENCE` is set.

A delegated token lets a third-party client message as a user who consented to
it. It is not an owner session: it cannot create, rotate or delete API keys,
administer sub-account grants, or manage push subscriptions.

## Obtaining a token

A resource server must not forward the bearer token it received from its own
client. It obtains a separate token for this API from the identity provider,
for example by OAuth 2.0 Token Exchange (RFC 8693), and sends that token as
`Authorization: Bearer <token>`.

## Token format

A JWT signed with EdDSA (Ed25519). The `kid` header must name a key in the JWKS
at `FMSG_JWT_JWKS_URL`. The `typ` header is not checked; `at+jwt` (RFC 9068) is
recommended.

| Claim | Required | Rule |
| ----- | -------- | ---- |
| `iss` | yes | Equals `FMSG_JWT_ISSUER`. |
| `aud` | yes | Contains `FMSG_JWT_OAUTH_AUDIENCE` and does not contain `FMSG_JWT_AUDIENCE`. A token carrying both is rejected with 401. |
| address claim | yes | The claim named by `FMSG_JWT_ADDRESS_CLAIM`, holding the consented identity as `@user@domain`. |
| `exp` | yes | Keep it short (minutes). Revoking a grant at the issuer takes effect here when outstanding tokens expire. |
| `iat`, `nbf` | no | Validated when present. |
| `scope` | yes | Space-delimited string. An array of strings under `scope` or `scp` is also accepted. |
| `act` | recommended | Actor claim (RFC 8693) naming the resource server. A token that carries `act` with the owner audience is rejected, which catches an issuer that mislabels the audience. |
| `client_id`, `jti` | no | Not interpreted; useful in issuer audit trails. |
| `fmsg_identities` | no | Array of `@user@domain` addresses the token may select with `X-FMSG-Act-As`. |

Issuers should omit claims that other services treat as proof of an owner
session.

## Scopes

| Scope | Routes |
| ----- | ------ |
| `fmsg:read` | `GET /fmsg`, `/fmsg/sent`, `/fmsg/:id`, `/fmsg/:id/data`, `/fmsg/:id/thread`, `/fmsg/:id/thread/messages`, `/fmsg/:id/attach/:filename`, `/fmsg/ws` |
| `fmsg:write` | `POST /fmsg`, `PUT`/`DELETE /fmsg/:id`, `POST /fmsg/:id/send`, `/read`, `/add-to`, `/react`, `/attach`, `DELETE /fmsg/:id/attach/:filename` |

Every other route is closed to delegated tokens whatever scopes they carry, and
new routes stay closed until added to the table in
`internal/middleware/oauth.go`. Unknown scope values are ignored.

Message permissions, quotas and fmsgid acceptance checks
apply to delegated tokens exactly as they do to owner sessions. Scopes only
narrow access; they never add to it.

## Failure behaviour

| Condition | Response |
| --------- | -------- |
| `scope` missing, empty, or not a string / array of strings | 403 |
| Route needs a scope the token lacks, or is closed to delegated tokens | 403 with `WWW-Authenticate: Bearer error="insufficient_scope"` |
| `aud` contains neither configured audience, or both | 401 |
| `act` present on an owner-audience token | 401 |
| `fmsg_identities` malformed | 403 |

A delegated token is never downgraded into, or mistaken for, an owner session:
the audience alone decides which kind of token it is, and a delegated token
that fails these checks is refused.

## Acting as another identity

`X-FMSG-Act-As` (and `act_as` on the WebSocket) is refused for delegated tokens
unless the requested address is listed in `fmsg_identities`. A listed address
must also pass the normal owner/sub-account grant check, so the claim can only
narrow what the owner could already do. Administration stays closed after
switching identity.

## WebSocket

A connection opened with a delegated token is closed when the token expires.
Reconnect with a fresh token.
41 changes: 41 additions & 0 deletions internal/handlers/subaccounts_oauth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package handlers

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/gin-gonic/gin"

"github.com/markmnl/fmsg-webapi/internal/middleware"
)

// Sub-account administration belongs to owner sessions only. This is the
// handler-level guard behind the middleware's delegated-token route table.
func TestRequireIdPOwnerByAuthType(t *testing.T) {
const owner = "@alice@example.com"
tests := []struct {
authType, identity string
want bool
}{
{middleware.AuthTypeIdP, owner, true},
{middleware.AuthTypeIdP, "@alice_bot@example.com", false},
{middleware.AuthTypeOAuth, owner, false},
{middleware.AuthTypeAPI, owner, false},
{"", owner, false},
}
for _, tt := range tests {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.AuthTypeKey, tt.authType)
c.Set(middleware.IdentityKey, tt.identity)
c.Set(middleware.OwnerIdentityKey, owner)
_, ok := requireIdPOwner(c)
if ok != tt.want {
t.Errorf("auth_type=%q identity=%s: ok=%v want %v", tt.authType, tt.identity, ok, tt.want)
}
if !ok && w.Code != http.StatusForbidden {
t.Errorf("auth_type=%q: status=%d want 403", tt.authType, w.Code)
}
}
}
Loading