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
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Issuer>/.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
Expand All @@ -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 |
Expand Down
137 changes: 124 additions & 13 deletions testdata/verify_only_vectors.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@
"mfa"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "user_otp",
Expand All @@ -136,7 +137,8 @@
"otp"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "user_stale_passkey",
Expand All @@ -159,7 +161,8 @@
"mfa"
],
"auth_time": 1798761000
}
},
"is_client": false
},
{
"name": "user_no_auth_time",
Expand All @@ -182,7 +185,8 @@
"mfa"
],
"auth_time": null
}
},
"is_client": false
},
{
"name": "client_credentials",
Expand All @@ -199,7 +203,8 @@
"acr": null,
"amr": [],
"auth_time": null
}
},
"is_client": true
},
{
"name": "aud_array",
Expand All @@ -220,7 +225,8 @@
"otp"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "typ_application",
Expand All @@ -241,7 +247,8 @@
"otp"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "typ_uppercase",
Expand All @@ -262,7 +269,8 @@
"otp"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "exp_within_skew",
Expand All @@ -283,7 +291,8 @@
"otp"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "nbf_within_skew",
Expand All @@ -304,7 +313,8 @@
"otp"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "rotated_key",
Expand All @@ -325,7 +335,8 @@
"otp"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "org_id_null",
Expand All @@ -346,7 +357,8 @@
"otp"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "unknown_claims",
Expand All @@ -367,7 +379,8 @@
"otp"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "wrong_aud",
Expand Down Expand Up @@ -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
}
}
]
}
57 changes: 45 additions & 12 deletions verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)))
})
}
}
Loading
Loading