diff --git a/README.md b/README.md index 0a9c4e8..7b0f977 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. | @@ -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 diff --git a/cmd/fmsg-webapi/main.go b/cmd/fmsg-webapi/main.go index dcc1f5c..358973f 100644 --- a/cmd/fmsg-webapi/main.go +++ b/cmd/fmsg-webapi/main.go @@ -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) @@ -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) } @@ -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, @@ -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)") } diff --git a/cmd/fmsg-webapi/routes_test.go b/cmd/fmsg-webapi/routes_test.go new file mode 100644 index 0000000..67bcbe8 --- /dev/null +++ b/cmd/fmsg-webapi/routes_test.go @@ -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) + } + } +} diff --git a/docs/oauth-claims.md b/docs/oauth-claims.md new file mode 100644 index 0000000..beb667a --- /dev/null +++ b/docs/oauth-claims.md @@ -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 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. diff --git a/internal/handlers/subaccounts_oauth_test.go b/internal/handlers/subaccounts_oauth_test.go new file mode 100644 index 0000000..7946dc7 --- /dev/null +++ b/internal/handlers/subaccounts_oauth_test.go @@ -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) + } + } +} diff --git a/internal/handlers/ws.go b/internal/handlers/ws.go index c51c780..9b728f3 100644 --- a/internal/handlers/ws.go +++ b/internal/handlers/ws.go @@ -98,6 +98,9 @@ func (h *WSHandler) Connect(c *gin.Context) { c.JSON(status, gin.H{"error": msg}) return } + if !middleware.CheckScope(c, res) { + return + } addr := res.Addr conn, err := h.upgrader.Upgrade(c.Writer, c.Request, nil) @@ -115,6 +118,13 @@ func (h *WSHandler) Connect(c *gin.Context) { } h.hub.Register(client) + // A delegated OAuth token's connection ends with the token, so a revoked + // grant cannot keep receiving; the client reconnects with a fresh token. + if !res.Expires.IsZero() { + expiry := time.AfterFunc(time.Until(res.Expires), client.close) + defer expiry.Stop() + } + go client.writePump() client.readPump() // blocks until the connection ends diff --git a/internal/handlers/ws_oauth_test.go b/internal/handlers/ws_oauth_test.go new file mode 100644 index 0000000..b6b50eb --- /dev/null +++ b/internal/handlers/ws_oauth_test.go @@ -0,0 +1,67 @@ +package handlers + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + + "github.com/markmnl/fmsg-webapi/internal/middleware" +) + +// A delegated OAuth token needs the read scope to open the WebSocket; the +// refusal happens before the upgrade. +func TestWSConnectRequiresReadScopeForDelegatedTokens(t *testing.T) { + idSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]bool{"acceptingNew": true}) + })) + defer idSrv.Close() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + verifier, err := middleware.NewVerifier(middleware.Config{ + JWKS: func(*jwt.Token) (any, error) { return pub, nil }, + Issuer: "https://issuer.example.test/", + Audience: "fmsg-web-client", + OAuthAudience: "https://api.example.test/fmsg", + AddressClaim: "sub", + IDURL: idSrv.URL, + }) + if err != nil { + t.Fatal(err) + } + router := gin.New() + router.GET("/fmsg/ws", NewWSHandler(verifier, NewHub(nil), nil).Connect) + + connect := func(scope string) int { + tok, err := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{ + "iss": "https://issuer.example.test/", "aud": "https://api.example.test/fmsg", + "sub": "@alice@example.com", "scope": scope, + "iat": time.Now().Unix(), "exp": time.Now().Add(time.Minute).Unix(), + }).SignedString(priv) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, "/fmsg/ws", nil) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w.Code + } + + if code := connect(middleware.ScopeWrite); code != http.StatusForbidden { + t.Fatalf("write-only token: expected 403, got %d", code) + } + // With the read scope the request passes authorization and fails only + // because this plain HTTP request is not a WebSocket upgrade. + if code := connect(middleware.ScopeRead); code != http.StatusBadRequest { + t.Fatalf("read token: expected 400 from the upgrader, got %d", code) + } +} diff --git a/internal/middleware/jwt.go b/internal/middleware/jwt.go index 8352b40..7b4f629 100644 --- a/internal/middleware/jwt.go +++ b/internal/middleware/jwt.go @@ -11,6 +11,7 @@ import ( "log" "net/http" "net/url" + "slices" "strings" "sync" "time" @@ -29,6 +30,9 @@ const ( AuthTypeIdP = "idp" AuthTypeAPI = "api_token" + // AuthTypeOAuth marks a delegated OAuth token: one issued to a third-party + // client on the owner's behalf. It never carries owner privileges. + AuthTypeOAuth = "oauth" ) // DefaultClockSkew is the leeway applied to iat/nbf/exp validation to tolerate @@ -50,6 +54,12 @@ type Config struct { Audience string AddressClaim string + // OAuthAudience enables delegated OAuth tokens. A provider token whose aud + // contains this value is scope-restricted and is never treated as an owner + // session. It requires Audience, and must differ from it, so that both + // kinds of token are identified positively by their audience. + OAuthAudience string + // Ed25519 first-party API-token verification. Enabled when APIPublicKey is non-empty. APIPublicKey ed25519.PublicKey APIIssuer string @@ -68,6 +78,10 @@ type authResult struct { Addr string OwnerAddr string AuthType string + + // Scopes and Expires are set for AuthTypeOAuth only. + Scopes map[string]struct{} + Expires time.Time } // Verifier verifies fmsg bearer tokens. It is safe for concurrent use and is @@ -77,6 +91,7 @@ type Verifier struct { idpKeyFunc jwt.Keyfunc issuer string audience string + oauthAud string addressClaim string apiParser *jwt.Parser apiPublicKey ed25519.PublicKey @@ -98,6 +113,9 @@ func NewVerifier(cfg Config) (*Verifier, error) { if cfg.AddressClaim == "" { return nil, errors.New("middleware: EdDSA mode requires an AddressClaim") } + if cfg.OAuthAudience != "" && (cfg.Audience == "" || cfg.Audience == cfg.OAuthAudience) { + return nil, errors.New("middleware: OAuthAudience requires a different, non-empty Audience") + } v.idpKeyFunc = func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodEd25519); !ok { return nil, fmt.Errorf("unexpected signing method: %s", t.Method.Alg()) @@ -111,12 +129,12 @@ func NewVerifier(cfg Config) (*Verifier, error) { jwt.WithIssuedAt(), jwt.WithIssuer(cfg.Issuer), } - if cfg.Audience != "" { - parserOpts = append(parserOpts, jwt.WithAudience(cfg.Audience)) - } + // The audience is checked in authenticateIdP: it decides whether a + // token is an owner session or a delegated OAuth token. v.idpParser = jwt.NewParser(parserOpts...) v.issuer = cfg.Issuer v.audience = cfg.Audience + v.oauthAud = cfg.OAuthAudience v.addressClaim = cfg.AddressClaim } @@ -185,6 +203,11 @@ func (v *Verifier) authenticateIdP(ctx context.Context, tokenStr, actAs string) return authResult{}, err } + delegated, err := v.classifyAudience(claims) + if err != nil { + return authResult{}, err + } + owner, _ := claims[v.addressClaim].(string) if owner == "" { sub, _ := claims["sub"].(string) @@ -195,6 +218,24 @@ func (v *Verifier) authenticateIdP(ctx context.Context, tokenStr, actAs string) return authResult{}, authError{status: status, msg: msg} } res := authResult{Addr: owner, OwnerAddr: owner, AuthType: AuthTypeIdP} + var identities []string + if delegated { + // Missing or malformed scopes deny; they never fall back to the + // privileges of an owner session. + scopes, err := parseScopes(claims) + if err != nil { + log.Printf("auth rejected: reason=oauth_scope addr=%s", owner) + return authResult{}, authError{status: http.StatusForbidden, msg: "missing or malformed scope"} + } + if identities, err = delegatedIdentities(claims); err != nil { + return authResult{}, authError{status: http.StatusForbidden, msg: "malformed identities claim"} + } + res.AuthType = AuthTypeOAuth + res.Scopes = scopes + if exp, err := claims.GetExpirationTime(); err == nil && exp != nil { + res.Expires = exp.Time + } + } if strings.TrimSpace(actAs) == "" { return res, nil @@ -203,6 +244,11 @@ func (v *Verifier) authenticateIdP(ctx context.Context, tokenStr, actAs string) return authResult{}, authError{status: http.StatusForbidden, msg: "act-as is not enabled"} } actAs = strings.TrimSpace(actAs) + // A delegated token may only select identities recorded in its grant; the + // owner/sub-account relationship below is still required as well. + if delegated && !slices.Contains(identities, actAs) { + return authResult{}, authError{status: http.StatusForbidden, msg: "act-as identity is not authorised for this token"} + } if !IsValidAddr(actAs) { return authResult{}, authError{status: http.StatusUnauthorized, msg: "invalid act-as identity"} } @@ -216,6 +262,41 @@ func (v *Verifier) authenticateIdP(ctx context.Context, tokenStr, actAs string) return res, nil } +// classifyAudience enforces the intended audience and reports whether the +// token is a delegated OAuth token. With no OAuthAudience configured every +// provider token is an owner session, as before. +func (v *Verifier) classifyAudience(claims jwt.MapClaims) (delegated bool, err error) { + if v.audience == "" { + return false, nil // OAuthAudience cannot be set without Audience + } + aud, err := claims.GetAudience() + if err != nil { + return false, err + } + ownerAud := audienceContains(aud, v.audience) + if v.oauthAud == "" { + if !ownerAud { + return false, jwt.ErrTokenInvalidAudience + } + return false, nil + } + oauthAud := audienceContains(aud, v.oauthAud) + switch { + case oauthAud && ownerAud: + return false, authError{status: http.StatusUnauthorized, msg: "ambiguous token audience"} + case oauthAud: + return true, nil + case !ownerAud: + return false, jwt.ErrTokenInvalidAudience + } + // An actor claim marks delegation (RFC 8693); it has no place on an owner + // session, so refuse it rather than grant owner privileges. + if _, has := claims["act"]; has { + return false, authError{status: http.StatusUnauthorized, msg: "delegated token presented with owner audience"} + } + return false, nil +} + func (v *Verifier) authenticateAPIToken(ctx context.Context, tokenStr, remoteAddr, actAs string) (authResult, error) { if strings.TrimSpace(actAs) != "" { return authResult{}, authError{status: http.StatusForbidden, msg: "act-as is only available with identity-provider authentication"} @@ -314,6 +395,9 @@ func New(cfg Config) (gin.HandlerFunc, error) { respondAuth(c, status, msg) return } + if !CheckScope(c, res) { + return + } c.Set(IdentityKey, res.Addr) c.Set(OwnerIdentityKey, res.OwnerAddr) diff --git a/internal/middleware/oauth.go b/internal/middleware/oauth.go new file mode 100644 index 0000000..5f3dc77 --- /dev/null +++ b/internal/middleware/oauth.go @@ -0,0 +1,154 @@ +package middleware + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// Scopes understood on delegated OAuth tokens. They gate which routes a +// delegated token may call; message permissions, quotas and acceptance checks +// still apply to every request exactly as they do for owner sessions. +const ( + ScopeRead = "fmsg:read" + ScopeWrite = "fmsg:write" +) + +// IdentitiesClaim optionally lists the addresses, besides the token's own +// address, that a delegated token may select with X-FMSG-Act-As. Listing an +// address never grants it: the owner/sub-account relationship is still checked. +const IdentitiesClaim = "fmsg_identities" + +// oauthRouteScopes is the complete set of routes available to delegated OAuth +// tokens, keyed by method and Gin route pattern. Any route absent from this +// table is denied to them, so new routes are closed to delegated tokens until +// listed here. Sub-account (API key and grant) administration and push +// subscriptions are deliberately absent. +var oauthRouteScopes = map[string]string{ + "GET /fmsg": ScopeRead, + "GET /fmsg/sent": ScopeRead, + "GET /fmsg/:id": ScopeRead, + "GET /fmsg/:id/data": ScopeRead, + "GET /fmsg/:id/thread": ScopeRead, + "GET /fmsg/:id/thread/messages": ScopeRead, + "GET /fmsg/:id/attach/:filename": ScopeRead, + "GET /fmsg/ws": ScopeRead, + "POST /fmsg": ScopeWrite, + "PUT /fmsg/:id": ScopeWrite, + "DELETE /fmsg/:id": ScopeWrite, + "POST /fmsg/:id/send": ScopeWrite, + "POST /fmsg/:id/read": ScopeWrite, + "POST /fmsg/:id/add-to": ScopeWrite, + "POST /fmsg/:id/react": ScopeWrite, + "POST /fmsg/:id/attach": ScopeWrite, + "DELETE /fmsg/:id/attach/:filename": ScopeWrite, +} + +// OAuthRouteScope returns the scope a delegated OAuth token needs for a route, +// and false when the route is closed to delegated tokens. +func OAuthRouteScope(method, routePattern string) (string, bool) { + scope, ok := oauthRouteScopes[method+" "+routePattern] + return scope, ok +} + +var errMalformedScope = errors.New("malformed scope claim") + +// parseScopes reads the scope claim of a delegated token: the space-delimited +// `scope` string of RFC 9068, or an array of strings under `scope` or `scp`. +// Any other shape is an error; callers must deny rather than assume privileges. +func parseScopes(claims jwt.MapClaims) (map[string]struct{}, error) { + raw, ok := claims["scope"] + if !ok { + raw, ok = claims["scp"] + } + if !ok { + return nil, errMalformedScope + } + var items []string + switch v := raw.(type) { + case string: + items = strings.Fields(v) + case []any: + for _, e := range v { + s, ok := e.(string) + if !ok || s == "" || strings.ContainsAny(s, " \t\r\n") { + return nil, errMalformedScope + } + items = append(items, s) + } + default: + return nil, errMalformedScope + } + if len(items) == 0 { + return nil, errMalformedScope + } + scopes := make(map[string]struct{}, len(items)) + for _, s := range items { + scopes[s] = struct{}{} + } + return scopes, nil +} + +// delegatedIdentities reads the optional IdentitiesClaim. A missing claim means +// no additional identities; a malformed one is an error. +func delegatedIdentities(claims jwt.MapClaims) ([]string, error) { + raw, ok := claims[IdentitiesClaim] + if !ok { + return nil, nil + } + list, ok := raw.([]any) + if !ok { + return nil, errors.New("malformed identities claim") + } + out := make([]string, 0, len(list)) + for _, e := range list { + s, ok := e.(string) + if !ok || !IsValidAddr(s) { + return nil, errors.New("malformed identities claim") + } + out = append(out, s) + } + return out, nil +} + +func audienceContains(aud jwt.ClaimStrings, want string) bool { + for _, a := range aud { + if a == want { + return true + } + } + return false +} + +// AllowsRoute reports whether the authenticated token may call the route. +// Owner-session and API-key tokens are not scope-restricted here. Delegated +// OAuth tokens need the scope listed for the route and are denied every route +// that is not listed. +func (r authResult) AllowsRoute(method, routePattern string) (ok bool, msg string) { + if r.AuthType != AuthTypeOAuth { + return true, "" + } + need, listed := OAuthRouteScope(method, routePattern) + if !listed { + return false, "this operation is not available to delegated OAuth tokens" + } + if _, has := r.Scopes[need]; !has { + return false, "token lacks required scope " + need + } + return true, "" +} + +// CheckScope aborts the request with 403 insufficient_scope when the token may +// not call the matched route. It returns true when the request may proceed. +func CheckScope(c *gin.Context, res authResult) bool { + ok, msg := res.AllowsRoute(c.Request.Method, c.FullPath()) + if ok { + return true + } + c.Header("WWW-Authenticate", `Bearer error="insufficient_scope"`) + respondAuth(c, http.StatusForbidden, msg) + return false +} diff --git a/internal/middleware/oauth_test.go b/internal/middleware/oauth_test.go new file mode 100644 index 0000000..2322911 --- /dev/null +++ b/internal/middleware/oauth_test.go @@ -0,0 +1,283 @@ +package middleware + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + + "github.com/markmnl/fmsg-webapi/internal/apiauth" +) + +const testOAuthAudience = "https://api.example.test/fmsg" + +// adminRoutes are the routes that must stay closed to delegated OAuth tokens. +var adminRoutes = [][2]string{ + {http.MethodGet, "/fmsg/sub-accounts"}, + {http.MethodPost, "/fmsg/sub-accounts"}, + {http.MethodGet, "/fmsg/sub-accounts/:agent"}, + {http.MethodPatch, "/fmsg/sub-accounts/:agent"}, + {http.MethodPost, "/fmsg/sub-accounts/:agent/rotate-key"}, + {http.MethodDelete, "/fmsg/sub-accounts/:agent"}, + {http.MethodPost, "/fmsg/push/subscribe"}, + {http.MethodDelete, "/fmsg/push/subscribe"}, +} + +type whoami struct { + Identity string `json:"identity"` + Owner string `json:"owner"` + AuthType string `json:"auth_type"` +} + +// oauthEngine serves every delegated and administrative route pattern behind +// the real authentication middleware, answering with the resolved identity. +func oauthEngine(t *testing.T, apiKeys APIKeyChecker) (*gin.Engine, ed25519.PrivateKey) { + t.Helper() + srv := fmsgIDServer(t, http.StatusOK, true) + t.Cleanup(srv.Close) + priv, jwks := newEdDSAFixture(t) + cfg := providerConfig(srv.URL, jwks) + cfg.OAuthAudience = testOAuthAudience + if apiKeys != nil { + apiPub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + cfg.APIPublicKey = apiPub + cfg.APIKeys = apiKeys + } + mw, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + r := gin.New() + r.Use(mw) + echo := func(c *gin.Context) { + c.JSON(http.StatusOK, whoami{GetIdentity(c), GetOwnerIdentity(c), GetAuthType(c)}) + } + for key := range oauthRouteScopes { + method, pattern, _ := strings.Cut(key, " ") + r.Handle(method, pattern, echo) + } + for _, route := range adminRoutes { + r.Handle(route[0], route[1], echo) + } + return r, priv +} + +func delegatedClaims(addr string, scope any) jwt.MapClaims { + claims := providerClaims(addr) + claims["aud"] = testOAuthAudience + claims["client_id"] = "https://client.example.test/metadata.json" + claims["act"] = map[string]any{"sub": "mcp-server"} + if scope != nil { + claims["scope"] = scope + } + return claims +} + +func call(t *testing.T, r *gin.Engine, method, path, token, actAs string) (*httptest.ResponseRecorder, whoami) { + t.Helper() + req := httptest.NewRequest(method, path, nil) + req.RemoteAddr = "127.0.0.1:12345" + req.Header.Set("Authorization", "Bearer "+token) + if actAs != "" { + req.Header.Set("X-FMSG-Act-As", actAs) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + var who whoami + _ = json.Unmarshal(w.Body.Bytes(), &who) + return w, who +} + +// concrete turns a route pattern into a request path. +func concrete(pattern string) string { + return strings.NewReplacer(":id", "42", ":filename", "a.txt", ":agent", "bot").Replace(pattern) +} + +func TestOAuth_MessagingWorksForConsentedIdentity(t *testing.T) { + r, priv := oauthEngine(t, nil) + tok := signEdDSA(t, priv, "prod-1", delegatedClaims("@alice@example.com", ScopeRead+" "+ScopeWrite)) + for key := range oauthRouteScopes { + method, pattern, _ := strings.Cut(key, " ") + w, who := call(t, r, method, concrete(pattern), tok, "") + if w.Code != http.StatusOK { + t.Fatalf("%s: expected 200, got %d body=%s", key, w.Code, w.Body.String()) + } + if who.Identity != "@alice@example.com" || who.Owner != "@alice@example.com" || who.AuthType != AuthTypeOAuth { + t.Fatalf("%s: unexpected identity %+v", key, who) + } + } +} + +func TestOAuth_ScopeArrayForms(t *testing.T) { + r, priv := oauthEngine(t, nil) + claims := delegatedClaims("@alice@example.com", nil) + claims["scp"] = []string{ScopeRead} + tok := signEdDSA(t, priv, "prod-1", claims) + if w, _ := call(t, r, http.MethodGet, "/fmsg", tok, ""); w.Code != http.StatusOK { + t.Fatalf("scp array: expected 200, got %d", w.Code) + } +} + +func TestOAuth_ScopeLimitsRoutes(t *testing.T) { + r, priv := oauthEngine(t, nil) + tok := signEdDSA(t, priv, "prod-1", delegatedClaims("@alice@example.com", ScopeRead)) + if w, _ := call(t, r, http.MethodGet, "/fmsg", tok, ""); w.Code != http.StatusOK { + t.Fatalf("read: expected 200, got %d", w.Code) + } + w, _ := call(t, r, http.MethodPost, "/fmsg/42/send", tok, "") + if w.Code != http.StatusForbidden { + t.Fatalf("send with read scope: expected 403, got %d", w.Code) + } + if got := w.Header().Get("WWW-Authenticate"); got != `Bearer error="insufficient_scope"` { + t.Fatalf("WWW-Authenticate=%q", got) + } +} + +func TestOAuth_AdministrationDenied(t *testing.T) { + r, priv := oauthEngine(t, fakeAPIKeys{}) + // Even a token claiming every scope, including invented ones, is refused. + tok := signEdDSA(t, priv, "prod-1", delegatedClaims("@alice@example.com", ScopeRead+" "+ScopeWrite+" fmsg:admin admin *")) + for _, route := range adminRoutes { + if w, _ := call(t, r, route[0], concrete(route[1]), tok, ""); w.Code != http.StatusForbidden { + t.Fatalf("%s %s: expected 403, got %d", route[0], route[1], w.Code) + } + } +} + +func TestOAuth_MissingOrMalformedScopeNeverGrantsOwner(t *testing.T) { + r, priv := oauthEngine(t, fakeAPIKeys{}) + cases := map[string]func(jwt.MapClaims){ + "missing": func(c jwt.MapClaims) {}, + "empty string": func(c jwt.MapClaims) { c["scope"] = "" }, + "blank string": func(c jwt.MapClaims) { c["scope"] = " " }, + "number": func(c jwt.MapClaims) { c["scope"] = 7 }, + "object": func(c jwt.MapClaims) { c["scope"] = map[string]any{"fmsg:read": true} }, + "empty array": func(c jwt.MapClaims) { c["scope"] = []string{} }, + "mixed array": func(c jwt.MapClaims) { c["scp"] = []any{ScopeRead, 1} }, + "spaced element": func(c jwt.MapClaims) { c["scp"] = []any{"fmsg:read fmsg:write"} }, + "null": func(c jwt.MapClaims) { c["scope"] = nil }, + } + for name, mutate := range cases { + claims := delegatedClaims("@alice@example.com", nil) + mutate(claims) + tok := signEdDSA(t, priv, "prod-1", claims) + for _, route := range [][2]string{{http.MethodGet, "/fmsg"}, {http.MethodPost, "/fmsg/sub-accounts"}} { + if w, _ := call(t, r, route[0], route[1], tok, ""); w.Code != http.StatusForbidden { + t.Fatalf("%s %s %s: expected 403, got %d body=%s", name, route[0], route[1], w.Code, w.Body.String()) + } + } + } +} + +func TestOAuth_AudienceEnforced(t *testing.T) { + r, priv := oauthEngine(t, nil) + cases := map[string]func(jwt.MapClaims){ + "both audiences": func(c jwt.MapClaims) { c["aud"] = []string{testAudience, testOAuthAudience} }, + "other resource audience": func(c jwt.MapClaims) { c["aud"] = "https://mcp.example.test/mcp" }, + "no audience": func(c jwt.MapClaims) { delete(c, "aud") }, + "actor on owner audience": func(c jwt.MapClaims) { c["aud"] = testAudience }, + } + for name, mutate := range cases { + claims := delegatedClaims("@alice@example.com", ScopeRead+" "+ScopeWrite) + mutate(claims) + tok := signEdDSA(t, priv, "prod-1", claims) + if w, _ := call(t, r, http.MethodGet, "/fmsg", tok, ""); w.Code != http.StatusUnauthorized { + t.Fatalf("%s: expected 401, got %d body=%s", name, w.Code, w.Body.String()) + } + } +} + +func TestOAuth_DelegatedAudienceRejectedUntilEnabled(t *testing.T) { + srv := fmsgIDServer(t, http.StatusOK, true) + defer srv.Close() + priv, jwks := newEdDSAFixture(t) + mw, err := New(providerConfig(srv.URL, jwks)) // no OAuthAudience + if err != nil { + t.Fatal(err) + } + tok := signEdDSA(t, priv, "prod-1", delegatedClaims("@alice@example.com", ScopeRead)) + if w := runMiddleware(t, mw, tok, ""); w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestOAuth_ActAsCannotBroadenGrant(t *testing.T) { + const sub = "@alice_bot@example.com" + // The owner does own the sub-account, yet the grant does not record it. + r, priv := oauthEngine(t, fakeAPIKeys{}) + tok := signEdDSA(t, priv, "prod-1", delegatedClaims("@alice@example.com", ScopeRead+" "+ScopeWrite)) + if w, _ := call(t, r, http.MethodGet, "/fmsg", tok, sub); w.Code != http.StatusForbidden { + t.Fatalf("unlisted act-as: expected 403, got %d", w.Code) + } + + for name, identities := range map[string]any{"string": sub, "invalid entry": []any{"bot"}, "mixed": []any{sub, 3}} { + claims := delegatedClaims("@alice@example.com", ScopeRead) + claims[IdentitiesClaim] = identities + bad := signEdDSA(t, priv, "prod-1", claims) + if w, _ := call(t, r, http.MethodGet, "/fmsg", bad, sub); w.Code != http.StatusForbidden { + t.Fatalf("malformed identities (%s): expected 403, got %d", name, w.Code) + } + } + + claims := delegatedClaims("@alice@example.com", ScopeRead+" "+ScopeWrite) + claims[IdentitiesClaim] = []string{sub} + listed := signEdDSA(t, priv, "prod-1", claims) + w, who := call(t, r, http.MethodGet, "/fmsg", listed, sub) + if w.Code != http.StatusOK || who.Identity != sub || who.Owner != "@alice@example.com" || who.AuthType != AuthTypeOAuth { + t.Fatalf("listed act-as: code=%d who=%+v", w.Code, who) + } + // Switching identity does not reopen administration. + for _, route := range adminRoutes { + if w, _ := call(t, r, route[0], concrete(route[1]), listed, sub); w.Code != http.StatusForbidden { + t.Fatalf("act-as %s %s: expected 403, got %d", route[0], route[1], w.Code) + } + } + + // Listing an identity the owner does not own grants nothing. + r, priv = oauthEngine(t, fakeAPIKeys{actErr: apiauth.ErrNotFound}) + listed = signEdDSA(t, priv, "prod-1", claims) + if w, _ := call(t, r, http.MethodGet, "/fmsg", listed, sub); w.Code != http.StatusForbidden { + t.Fatalf("listed but unowned: expected 403, got %d", w.Code) + } +} + +func TestOAuth_OwnerSessionsUnchanged(t *testing.T) { + r, priv := oauthEngine(t, fakeAPIKeys{}) + claims := providerClaims("@alice@example.com") + // Some providers put a scope claim on ordinary sign-in tokens. + claims["scope"] = "openid profile" + tok := signEdDSA(t, priv, "prod-1", claims) + for _, route := range adminRoutes { + w, who := call(t, r, route[0], concrete(route[1]), tok, "") + if w.Code != http.StatusOK || who.AuthType != AuthTypeIdP { + t.Fatalf("owner %s %s: code=%d who=%+v", route[0], route[1], w.Code, who) + } + } + w, who := call(t, r, http.MethodPost, "/fmsg", tok, "@alice_bot@example.com") + if w.Code != http.StatusOK || who.Identity != "@alice_bot@example.com" || who.AuthType != AuthTypeIdP { + t.Fatalf("owner act-as: code=%d who=%+v", w.Code, who) + } +} + +func TestOAuth_ConfigValidation(t *testing.T) { + _, jwks := newEdDSAFixture(t) + cfg := providerConfig("http://127.0.0.1:1", jwks) + cfg.OAuthAudience = cfg.Audience + if _, err := New(cfg); err == nil { + t.Fatal("expected error when OAuthAudience equals Audience") + } + cfg.Audience = "" + cfg.OAuthAudience = testOAuthAudience + if _, err := New(cfg); err == nil { + t.Fatal("expected error when OAuthAudience is set without Audience") + } +}