From ddd6578aa57bef04a31921c8e5e06d1b35002c6b Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 22:25:36 -0500 Subject: [PATCH 1/2] test: hold verify-only mode to the user_required vectors Copy the shared verify-only vectors, which add is_client to each valid verify entry and a user_required_requests list run with allow_clients false. Assert IsClient on the verified user and run those requests through RequireTokenWith(AllowClients(false)). Refs fells-code/seamless-auth-server#260 --- testdata/verify_only_vectors.json | 137 +++++++++++++++++++++++++++--- verifier_test.go | 93 ++++++++++++++------ 2 files changed, 192 insertions(+), 38 deletions(-) diff --git a/testdata/verify_only_vectors.json b/testdata/verify_only_vectors.json index 8c0a71c..6ba46f4 100644 --- a/testdata/verify_only_vectors.json +++ b/testdata/verify_only_vectors.json @@ -115,7 +115,8 @@ "mfa" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "user_otp", @@ -136,7 +137,8 @@ "otp" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "user_stale_passkey", @@ -159,7 +161,8 @@ "mfa" ], "auth_time": 1798761000 - } + }, + "is_client": false }, { "name": "user_no_auth_time", @@ -182,7 +185,8 @@ "mfa" ], "auth_time": null - } + }, + "is_client": false }, { "name": "client_credentials", @@ -199,7 +203,8 @@ "acr": null, "amr": [], "auth_time": null - } + }, + "is_client": true }, { "name": "aud_array", @@ -220,7 +225,8 @@ "otp" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "typ_application", @@ -241,7 +247,8 @@ "otp" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "typ_uppercase", @@ -262,7 +269,8 @@ "otp" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "exp_within_skew", @@ -283,7 +291,8 @@ "otp" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "nbf_within_skew", @@ -304,7 +313,8 @@ "otp" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "rotated_key", @@ -325,7 +335,8 @@ "otp" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "org_id_null", @@ -346,7 +357,8 @@ "otp" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "unknown_claims", @@ -367,7 +379,8 @@ "otp" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "wrong_aud", @@ -1007,5 +1020,103 @@ "www_authenticate": "Bearer error=\"insufficient_user_authentication\", error_description=\"More recent authentication is required\", max_age=\"0\"" } } + ], + "user_required_requests": [ + { + "name": "user_required_client_credentials", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InN2Yy1yZXBvcnRzIiwiY2xpZW50X2lkIjoic3ZjLXJlcG9ydHMiLCJzY29wZSI6InJlcG9ydHM6cmVhZCJ9.PaT0ELDOlyKU5q_6CMxkjZYyz2jYhB1djlGIB0ebLcDBZO2gJDna2AxJTa4QKmREIcJOZAMGsyzAp8ojLNeGdJXuClbGNxHaStrPyLdu5sYyypWqTdy3sHBXaf04YjE6CmaREcNt6TymXF6n52BdgxCjVDfsmDJ30JCCQ-RCCf1HfLZHVnf8Y_I19R4lzJ1SuDVENi6x_LVIGxg0FGF4WkVq0_6lXItF01nAGb1Rg-GrfaYBBf-SEWiYedUHd7tUXH3btZeZ20g4XlBqifHonax9MBJpOeyOUG_NNDHYoSBj7tV3Qk5Vwb9exuSrJMroW33mjvqSakRc9PD67X5tqw", + "auth": { + "allow_clients": false + }, + "guard": null, + "expect": { + "status": 403, + "body": { + "error": "user_required" + }, + "www_authenticate": null + } + }, + { + "name": "user_required_user", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.Q9__hloZFJqysviQX_klUPKyZjdLSWcV0YQyFMMRzneBiHjEKMEuAVH3MLNMOZV1kp3SY_BrPb9ihRQULVYNIJu9og-kv0G0Wcd7at6RfNwfr41mY5BXA4r35FUgMOwdHvuSG0MMWUWeStzKCUgxlz7MV5S5Ye9ahl8G0Gb0dQHKV7x0W2t3a098oRseQvu2PjA67ad5W7HBSIArRSp0Z5mdq-adYYmcTIf-kZtEyFsp25gNUqoHrLZVoXyIfGwdMhWF9BVEtVAa47QiK13L5kulko4tJbTSA5gEDqfyer4Fcp8KXAcEr92VASYMqm-y50rZHYoOV7NCPaKjL3AhUQ", + "auth": { + "allow_clients": false + }, + "guard": null, + "expect": { + "status": 200, + "body": null, + "www_authenticate": null + } + }, + { + "name": "user_required_missing_token", + "authorization": null, + "auth": { + "allow_clients": false + }, + "guard": null, + "expect": { + "status": 401, + "body": { + "error": "missing_token" + }, + "www_authenticate": "Bearer" + } + }, + { + "name": "user_required_invalid_token_first", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2MTU0MCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.EHGExwM8o7uCLrFolhwJ4P9mU4V6aW_wR2O3Ezt75jMjhkzBqGivaX5L5xHQOsMdbU_yg_54OxGjBEDoQ4RLz3nqfBf-VhjgJNyGjzfQaJ-KlMUFN0ZtM5zf_8-vRBy1zfTJF6ebzDs8ILxMzJTcrQq5U0DOp0xXO20rzYR84Us46Gs4sprpxHlFJrnEeoQl6LoHrPiMl19jarNHAFmiy-K4TooZ8Zhl0Pb6ML29FJucLc0dL8pBZ8C5PeWVBQ6ZO8jnvfR-_v7gxytMaH9W-8QbF7dzDHz3XSmZUhAEY1vpP676j6idsccG3TY_Q9KKaw47Px3bV7Hv3ubzrwXaRQ", + "auth": { + "allow_clients": false + }, + "guard": null, + "expect": { + "status": 401, + "body": { + "error": "invalid_token" + }, + "www_authenticate": "Bearer error=\"invalid_token\"" + } + }, + { + "name": "user_required_before_scope", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InN2Yy1yZXBvcnRzIiwiY2xpZW50X2lkIjoic3ZjLXJlcG9ydHMiLCJzY29wZSI6InJlcG9ydHM6cmVhZCJ9.PaT0ELDOlyKU5q_6CMxkjZYyz2jYhB1djlGIB0ebLcDBZO2gJDna2AxJTa4QKmREIcJOZAMGsyzAp8ojLNeGdJXuClbGNxHaStrPyLdu5sYyypWqTdy3sHBXaf04YjE6CmaREcNt6TymXF6n52BdgxCjVDfsmDJ30JCCQ-RCCf1HfLZHVnf8Y_I19R4lzJ1SuDVENi6x_LVIGxg0FGF4WkVq0_6lXItF01nAGb1Rg-GrfaYBBf-SEWiYedUHd7tUXH3btZeZ20g4XlBqifHonax9MBJpOeyOUG_NNDHYoSBj7tV3Qk5Vwb9exuSrJMroW33mjvqSakRc9PD67X5tqw", + "auth": { + "allow_clients": false + }, + "guard": { + "kind": "scope", + "scopes": [ + "reports:read" + ] + }, + "expect": { + "status": 403, + "body": { + "error": "user_required" + }, + "www_authenticate": null + } + }, + { + "name": "user_required_user_with_scope", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItMTIzIiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3BlbmlkIG9yZGVyczpyZWFkIG9yZGVyczp3cml0ZSIsInNpZCI6InNlc3MtMSIsIm9yZ19pZCI6Im9yZy05Iiwicm9sZXMiOlsib3JkZXJzOndyaXRlIiwibWVtYmVyIl0sImFjciI6InBocmgiLCJhbXIiOlsiaHdrIiwidXNlciIsIm1mYSJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.KfHFZpgrLcSwnOn81Wu49u5ihVB8BhLjf2DnUCtN4BOsOkUOQRtYV31DCevRzcN_FBFMpxt-b5WXOKAGml6nouXOAntr26DKBkeHuFU9LsVmZHOtxtNGy3VpuLK2qhVizzrD-Ol8MffP0fuPhV827itGV9Aea9XD6Mdi1sM54wYgvWjvpd0BChdTHPFQY4eq6GpsmTUSOnee-k0WiwbgKnML0kFlejQ_d8Qo4M1De10zV6d87s12h5MP8VTm4RJWgVmEzIhH_Och5sx77GIS-0IrLV6aKV8sil3HbKvKzKsWKwTCpIvBnyY2hEC98q5vv07074-O5r-Q4aMGPWJahA", + "auth": { + "allow_clients": false + }, + "guard": { + "kind": "scope", + "scopes": [ + "orders:write" + ] + }, + "expect": { + "status": 200, + "body": null, + "www_authenticate": null + } + } ] } diff --git a/verifier_test.go b/verifier_test.go index 4a90952..66e529b 100644 --- a/verifier_test.go +++ b/verifier_test.go @@ -42,24 +42,31 @@ type verifyOnlyVectors struct { AMR []string `json:"amr"` AuthTime *int64 `json:"auth_time"` } `json:"user"` + IsClient bool `json:"is_client"` } `json:"verify"` - Requests []struct { - Name string `json:"name"` - Authorization *string `json:"authorization"` - Guard *struct { - Kind string `json:"kind"` - Scopes []string `json:"scopes"` - Roles []string `json:"roles"` - ACRValues []string `json:"acr_values"` - AMR []string `json:"amr"` - MaxAge *int64 `json:"max_age"` - } `json:"guard"` - Expect struct { - Status int `json:"status"` - Body json.RawMessage `json:"body"` - WWWAuthenticate *string `json:"www_authenticate"` - } `json:"expect"` - } `json:"requests"` + Requests []vectorRequest `json:"requests"` + UserRequiredRequests []vectorRequest `json:"user_required_requests"` +} + +type vectorRequest struct { + Name string `json:"name"` + Authorization *string `json:"authorization"` + Auth *struct { + AllowClients *bool `json:"allow_clients"` + } `json:"auth"` + Guard *struct { + Kind string `json:"kind"` + Scopes []string `json:"scopes"` + Roles []string `json:"roles"` + ACRValues []string `json:"acr_values"` + AMR []string `json:"amr"` + MaxAge *int64 `json:"max_age"` + } `json:"guard"` + Expect struct { + Status int `json:"status"` + Body json.RawMessage `json:"body"` + WWWAuthenticate *string `json:"www_authenticate"` + } `json:"expect"` } func loadVectors(t *testing.T) verifyOnlyVectors { @@ -72,7 +79,7 @@ func loadVectors(t *testing.T) verifyOnlyVectors { if err := json.Unmarshal(data, &v); err != nil { t.Fatal(err) } - if len(v.Verify) == 0 || len(v.Requests) == 0 { + if len(v.Verify) == 0 || len(v.Requests) == 0 || len(v.UserRequiredRequests) == 0 { t.Fatal("no vectors") } return v @@ -136,8 +143,8 @@ func TestVerifyOnlyVectorsVerify(t *testing.T) { if want.AuthTime != nil { wantAuthTime = time.Unix(*want.AuthTime, 0) } - got := []any{user.Subject, user.ClientID, user.Scopes, user.Roles, user.SID, user.OrgID, user.ACR, user.AMR, user.AuthTime.Equal(wantAuthTime), user.AuthTime.IsZero()} - expected := []any{want.Sub, want.ClientID, want.Scopes, want.Roles, orEmpty(want.SID), orEmpty(want.OrgID), orEmpty(want.ACR), want.AMR, true, want.AuthTime == nil} + got := []any{user.Subject, user.ClientID, user.Scopes, user.Roles, user.SID, user.OrgID, user.ACR, user.AMR, user.AuthTime.Equal(wantAuthTime), user.AuthTime.IsZero(), user.IsClient} + expected := []any{want.Sub, want.ClientID, want.Scopes, want.Roles, orEmpty(want.SID), orEmpty(want.OrgID), orEmpty(want.ACR), want.AMR, true, want.AuthTime == nil, vec.IsClient} if !reflect.DeepEqual(got, expected) { t.Fatalf("got %#v, want %#v", got, expected) } @@ -145,7 +152,9 @@ func TestVerifyOnlyVectorsVerify(t *testing.T) { } } -func vectorRoute(t *testing.T, v *Verifier, guardKind string, scopes, roles, acr, amr []string, maxAge *int64) http.Handler { +// vectorRoute guards the route with RequireToken, or with RequireTokenWith when +// allowClients is set. +func vectorRoute(t *testing.T, v *Verifier, allowClients *bool, guardKind string, scopes, roles, acr, amr []string, maxAge *int64) http.Handler { t.Helper() ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if _, found := TokenUserFromContext(r.Context()); !found { @@ -175,22 +184,39 @@ func vectorRoute(t *testing.T, v *Verifier, guardKind string, scopes, roles, acr t.Fatalf("unknown guard %q", guardKind) } mux := http.NewServeMux() - mux.Handle("GET /resource", v.RequireToken(inner)) + if allowClients == nil { + mux.Handle("GET /resource", v.RequireToken(inner)) + } else { + mux.Handle("GET /resource", v.RequireTokenWith(AllowClients(*allowClients))(inner)) + } return mux } func TestVerifyOnlyVectorsRequests(t *testing.T) { vectors := loadVectors(t) - v := vectorVerifier(t, vectors, nil) - for _, vec := range vectors.Requests { + runVectorRequests(t, vectorVerifier(t, vectors, nil), vectors.Requests) +} + +func TestVerifyOnlyVectorsUserRequiredRequests(t *testing.T) { + vectors := loadVectors(t) + runVectorRequests(t, vectorVerifier(t, vectors, nil), vectors.UserRequiredRequests) +} + +func runVectorRequests(t *testing.T, v *Verifier, requests []vectorRequest) { + t.Helper() + for _, vec := range requests { t.Run(vec.Name, func(t *testing.T) { + var allowClients *bool + if vec.Auth != nil { + allowClients = vec.Auth.AllowClients + } kind := "" var scopes, roles, acr, amr []string var maxAge *int64 if g := vec.Guard; g != nil { kind, scopes, roles, acr, amr, maxAge = g.Kind, g.Scopes, g.Roles, g.ACRValues, g.AMR, g.MaxAge } - server := httptest.NewServer(vectorRoute(t, v, kind, scopes, roles, acr, amr, maxAge)) + server := httptest.NewServer(vectorRoute(t, v, allowClients, kind, scopes, roles, acr, amr, maxAge)) defer server.Close() req, _ := http.NewRequest(http.MethodGet, server.URL+"/resource", nil) @@ -232,6 +258,23 @@ func TestVerifyOnlyVectorsRequests(t *testing.T) { } } +func TestRequireTokenWithAllowsClientsByDefault(t *testing.T) { + vectors := loadVectors(t) + v := vectorVerifier(t, vectors, nil) + for name, mw := range map[string]func(http.Handler) http.Handler{ + "no options": v.RequireTokenWith(), + "AllowClients true": v.RequireTokenWith(AllowClients(true)), + } { + rec := httptest.NewRecorder() + mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })).ServeHTTP(rec, bearerRequest(vectors.Tokens["client_credentials"])) + if rec.Code != http.StatusNoContent { + t.Errorf("%s: %d %s", name, rec.Code, rec.Body) + } + } +} + func TestVerifierConfirmationHook(t *testing.T) { vectors := loadVectors(t) var seen map[string]any From 5cfebe138107c6ba471593a2e12d643f88c321f4 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 22:25:58 -0500 Subject: [PATCH 2/2] feat: let verify-only resource servers require a user TokenUser gains IsClient, true for a client credentials token (sub equals client_id). RequireTokenWith(AllowClients(false)) refuses such a token with 403 user_required, without a WWW-Authenticate challenge, after it verifies and before any guard inside runs. AllowClients defaults to true, so RequireToken is unchanged. Refs fells-code/seamless-auth-server#260 --- README.md | 17 +++++++++++++--- verifier.go | 57 ++++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 5ddee87..7f40160 100644 --- a/README.md +++ b/README.md @@ -175,9 +175,19 @@ mux.Handle("GET /admin", rs.RequireToken(rs.RequireRole("orders:admin")(http.Han ``` `RequireToken` puts a `*TokenUser` on the request context (`TokenUserFromContext`): `Subject`, -`ClientID`, `Scopes`, `Roles`, `SID`, `OrgID`, `ACR`, `AMR` and `AuthTime`. A client credentials -token is valid too; its `Subject` is the client and it has no user claims, so it can pass a scope -guard but never a role or step-up guard. `Authenticate` does the same check without answering. +`ClientID`, `IsClient`, `Scopes`, `Roles`, `SID`, `OrgID`, `ACR`, `AMR` and `AuthTime`. A client +credentials token is valid too; its `Subject` is the client (`IsClient` is true) and it has no user +claims, so it can pass a scope guard but never a role or step-up guard. `Authenticate` does the +same check without answering. + +To accept only tokens issued to a user, use `RequireTokenWith(seamlessauth.AllowClients(false))`. +A client credentials token is then refused with 403 `user_required` once it verifies, before any +guard inside it runs. `AllowClients` defaults to true, which is what `RequireToken` does. + +```go +userOnly := rs.RequireTokenWith(seamlessauth.AllowClients(false)) +mux.Handle("GET /me/orders", userOnly(rs.RequireScope("orders:read")(http.HandlerFunc(mine)))) +``` The keys come from `/.well-known/openid-configuration`, whose `issuer` must equal `Issuer` exactly and whose `jwks_uri` must be https when the issuer is. Set `JWKSURI` to skip @@ -194,6 +204,7 @@ it, which is where a DPoP proof check goes. | --- | --- | --- | --- | | No bearer token | 401 | `Bearer` | `{"error":"missing_token"}` | | Token does not verify | 401 | `Bearer error="invalid_token"` | `{"error":"invalid_token"}` | +| Client token, `AllowClients(false)` | 403 | none | `{"error":"user_required"}` | | `RequireScope` (every scope) | 403 | `Bearer error="insufficient_scope", scope="..."` | `{"error":"insufficient_scope","scope":"..."}` | | `RequireRole` (any role) | 403 | none | `{"error":"insufficient_role"}` | | `RequireStepUp` | 401 | RFC 9470 `insufficient_user_authentication` challenge | the same members as JSON | diff --git a/verifier.go b/verifier.go index 9bfabb5..34a9502 100644 --- a/verifier.go +++ b/verifier.go @@ -64,6 +64,9 @@ type Verifier struct { type TokenUser struct { Subject string ClientID string + // IsClient marks a client credentials token, whose Subject is its ClientID: a + // machine acting for itself, with no user behind it. + IsClient bool // Scopes is the scope claim split on spaces, in order. Scopes []string Roles []string @@ -352,6 +355,7 @@ func tokenUserFrom(claims map[string]any) (*TokenUser, error) { if user.ClientID, ok = claims["client_id"].(string); !ok || user.ClientID == "" { return nil, errors.New("no client_id") } + user.IsClient = user.Subject == user.ClientID if exp, ok := numberClaim(claims, "exp"); ok { user.ExpiresAt = timeFromSeconds(exp) } @@ -448,16 +452,45 @@ func writeMissingToken(w http.ResponseWriter) { // RequireToken answers 401 unless the request carries a valid access token, and // puts the TokenUser on the request context for next. Guards go inside it. func (v *Verifier) RequireToken(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, err := v.Authenticate(r) - if errors.Is(err, ErrMissingToken) { - writeMissingToken(w) - return - } - if err != nil { - writeBearerError(w, http.StatusUnauthorized, `Bearer error="invalid_token"`, map[string]string{"error": "invalid_token"}) - return - } - next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), tokenUserKey{}, user))) - }) + return v.RequireTokenWith()(next) +} + +// TokenOption configures RequireTokenWith. +type TokenOption func(*tokenOptions) + +type tokenOptions struct { + allowClients bool +} + +// AllowClients sets whether a client credentials token (TokenUser.IsClient) is +// accepted. It is by default. With false, such a token is refused with 403 +// user_required, so only a token issued to a user gets through. +func AllowClients(allow bool) TokenOption { + return func(o *tokenOptions) { o.allowClients = allow } +} + +// RequireTokenWith is RequireToken with options. +func (v *Verifier) RequireTokenWith(opts ...TokenOption) func(http.Handler) http.Handler { + o := tokenOptions{allowClients: true} + for _, opt := range opts { + opt(&o) + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, err := v.Authenticate(r) + if errors.Is(err, ErrMissingToken) { + writeMissingToken(w) + return + } + if err != nil { + writeBearerError(w, http.StatusUnauthorized, `Bearer error="invalid_token"`, map[string]string{"error": "invalid_token"}) + return + } + if user.IsClient && !o.allowClients { + writeBearerError(w, http.StatusForbidden, "", map[string]string{"error": "user_required"}) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), tokenUserKey{}, user))) + }) + } }