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
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,27 @@ issuer and audience, in date, of type `access`, and for the cookie's subject. `U
leaked `CookieSecret` cannot grant itself roles. If the JWKS cannot be fetched and no key set is
cached, the session is refused with 401.

`User.OrganizationID` is the active organization: the `org_id` claim of that verified access
token, for a cookie or a bearer session alike, and empty when the session has none. The copy of
the organization in the cookie is never read.

### Roles

`RequireRole` is `RequireAuth` that also checks the session's roles. It admits the request when
any of the roles it lists is granted by any of the user's roles, matched as `RoleGrantsAccess`
does (`billing:*` grants `billing:read`, `billing:write` grants `billing:read`):

```go
mux.Handle("GET /api/reports", auth.RequireRole("admin", "reports:read")(reports))
```

The roles come from the verified access token, never from the cookie. It answers the way
`RequireAuth` does for a missing session (401 `unauthenticated`), a recovery session (403
`enrollment_required`, unless `AllowRecoverySessions` is set) and a cross-site cookie request
(403 `cross_site_request_blocked`), and 403 `insufficient_role` when no listed role is granted.
No body names the user's roles or the required ones. `RequireRole` panics when given no role or
an empty one.

### Recovery sessions

Redeeming an account recovery code issues a recovery session: its access token carries
Expand Down Expand Up @@ -217,10 +238,10 @@ g.GET("/orders", wrap(rs.RequireToken), wrap(rs.RequireScope("orders:read")), li
| `CookieSameSite` | `None`, or `Lax` with `InsecureCookies` | Cookie `SameSite` |
| `AllowedOrigins` | none | The only cross-origin and same-site callers allowed to change state while cookies are `SameSite=None` |
| `AccessCookieName`, `RefreshCookieName` | `seamless-access`, `seamless-refresh` | Session cookie names |
| `RegistrationCookieName`, `PreAuthCookieName` | `seamless-ephemeral` | Sign-in flow cookie names |
| `RegistrationCookieName`, `PreAuthCookieName` | `seamless-ephemeral` | Sign-in flow cookie names. Logout clears both |
| `Deliver` | none | Sends OTP codes and magic links through your own transports |
| `DeliverAccountNotices` | `false` | `Deliver` also sends account notices. See below |
| `AllowRecoverySessions` | `false` | `RequireAuth` admits a recovery session |
| `AllowRecoverySessions` | `false` | `RequireAuth` and `RequireRole` admit a recovery session |
| `ResolveClientIP` | the connecting peer | The end user's address. See below |
| `DisableManifestFetch` | `false` | Use only the manifest bundled with this version |
| `HTTPClient`, `Logf` | defaults | Outbound client and logger. The client never follows a redirect: a supplied one is copied with a `CheckRedirect` that refuses them, and gets a 15 second timeout if it has none |
Expand Down
14 changes: 14 additions & 0 deletions adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,20 @@ func TestLogoutAnswers204AndClearsEvenWhenTheAPIRefuses(t *testing.T) {
}
}

func TestLogoutClearsACustomPreAuthCookie(t *testing.T) {
api := newFakeAPI(t)
api.on("DELETE", "/logout", respond(200, map[string]any{"message": "ok"}))
a := newTestAdapter(t, api, func(o *Options) { o.PreAuthCookieName = "app-pre-auth" })
access := signedCookie(t, "seamless-access", map[string]any{"sub": "u1", "token": "t"})

r := do(t, a, "DELETE", "/logout", "", withCookie(access))
for _, name := range []string{"seamless-access", "seamless-ephemeral", "seamless-refresh", "app-pre-auth"} {
if c := r.cookies[name]; c == nil || c.MaxAge >= 0 {
t.Fatalf("%s not cleared", name)
}
}
}

func TestDeliveryRoutesHandTheMessageToTheApplication(t *testing.T) {
api := newFakeAPI(t)
api.on("POST", "/otp/generate-login-email-otp", respond(200, map[string]any{
Expand Down
42 changes: 42 additions & 0 deletions guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ type User struct {
Roles []string
Email string
Phone string
// OrganizationID is the active organization, the org_id claim of the verified
// access token. Empty when the session has none.
OrganizationID string
// Token is the auth API access token, for calling the API on the user's behalf.
Token string
// Recovery is a session a recovery code issued, before the user enrolled a
Expand Down Expand Up @@ -71,6 +74,7 @@ func (a *Adapter) session(r *http.Request) (*User, bool, error) {
}
user := userFrom(claims, token)
user.Roles = rolesFrom(inner)
user.OrganizationID = stringClaim(inner, "org_id")
user.Recovery = inner["recovery"] == true
return user, true, nil
}
Expand All @@ -84,6 +88,7 @@ func (a *Adapter) session(r *http.Request) (*User, bool, error) {
return nil, false, errUnauthenticated
}
user := userFrom(claims, token)
user.OrganizationID = stringClaim(claims, "org_id")
user.Recovery = claims["recovery"] == true
return user, false, nil
}
Expand Down Expand Up @@ -130,6 +135,39 @@ func rolesFrom(claims map[string]any) []string {
// for a cross-site state change, as on the auth routes, and a recovery session
// with 403 enrollment_required unless AllowRecoverySessions is set.
func (a *Adapter) RequireAuth(next http.Handler) http.Handler {
return a.requireSession(nil, next)
}

// RequireRole is RequireAuth that also answers 403 insufficient_role unless the
// session's roles grant at least one of roles, matched as RoleGrantsAccess does.
// The roles are those of the verified access token. It panics when given no
// role, or an empty one.
func (a *Adapter) RequireRole(roles ...string) func(http.Handler) http.Handler {
if len(roles) == 0 {
panic("seamlessauth: RequireRole needs at least one role")
}
for _, role := range roles {
if trimJS(role) == "" {
panic("seamlessauth: RequireRole was given an empty role")
}
}
roles = append([]string(nil), roles...)
allowed := func(user *User) bool {
for _, required := range roles {
for _, granted := range user.Roles {
if RoleGrantsAccess(granted, required) {
return true
}
}
}
return false
}
return func(next http.Handler) http.Handler {
return a.requireSession(allowed, next)
}
}

func (a *Adapter) requireSession(allowed func(*User) bool, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, byCookie, err := a.authenticate(r)
if errors.Is(err, ErrEnrollmentRequired) {
Expand All @@ -148,6 +186,10 @@ func (a *Adapter) RequireAuth(next http.Handler) http.Handler {
return
}
}
if allowed != nil && !allowed(user) {
writeJSON(w, http.StatusForbidden, map[string]any{"error": "insufficient_role"})
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey{}, user)))
})
}
127 changes: 127 additions & 0 deletions guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,130 @@ func TestACookieSessionFailsClosedWhenTheKeySetIsUnreachable(t *testing.T) {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
}

func TestRequireRole(t *testing.T) {
api := newFakeAPI(t)
a := newTestAdapter(t, api)
token := func(claims map[string]any) string {
body := map[string]any{"sub": "u1", "typ": "access", "iss": api.server.URL, "aud": api.server.URL}
for k, v := range claims {
body[k] = v
}
return signRS256(t, body)
}
bearer := func(claims map[string]any) func(*http.Request) {
return withHeader("Authorization", "Bearer "+token(claims))
}
h := a.RequireRole("admin", "billing:read")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, _ := UserFromContext(r.Context())
writeJSON(w, 200, map[string]any{"id": user.ID})
}))
post := func(r *http.Request) { r.Method = http.MethodPost }

cases := []struct {
name string
setup []func(*http.Request)
status int
body string
}{
{"granted", []func(*http.Request){bearer(map[string]any{"roles": []string{"admin"}})}, 200, ""},
{"granted by another listed role", []func(*http.Request){bearer(map[string]any{"roles": []string{"billing:write"}})}, 200, ""},
{"granted by a wildcard", []func(*http.Request){bearer(map[string]any{"roles": []string{"billing:*"}})}, 200, ""},
{"granted on a cookie session", []func(*http.Request){withCookie(signedCookie(t, "seamless-access", map[string]any{
"sub": "u1", "token": token(map[string]any{"roles": []string{"admin"}}),
}))}, 200, ""},
{"refused", []func(*http.Request){bearer(map[string]any{"roles": []string{"user"}})}, 403, "insufficient_role"},
{"no roles", []func(*http.Request){bearer(nil)}, 403, "insufficient_role"},
// The cookie's own roles are signed only by the cookie secret.
{"cookie roles are not trusted", []func(*http.Request){withCookie(signedCookie(t, "seamless-access", map[string]any{
"sub": "u1", "token": token(map[string]any{"roles": []string{"user"}}), "roles": []string{"admin"},
}))}, 403, "insufficient_role"},
{"no session", nil, 401, "unauthenticated"},
{"ephemeral token", []func(*http.Request){bearer(map[string]any{"typ": "ephemeral", "roles": []string{"admin"}})}, 401, "unauthenticated"},
{"recovery session", []func(*http.Request){bearer(map[string]any{"roles": []string{"admin"}, "recovery": true})}, 403, "enrollment_required"},
{"cookie session, cross-site", []func(*http.Request){post, withHeader("Sec-Fetch-Site", "cross-site"), withCookie(signedCookie(t, "seamless-access", map[string]any{
"sub": "u1", "token": token(map[string]any{"roles": []string{"admin"}}),
}))}, 403, "cross_site_request_blocked"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rec := call(h, c.setup...)
if rec.Code != c.status {
t.Fatalf("status %d, want %d: %s", rec.Code, c.status, rec.Body.String())
}
if c.body != "" {
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if len(got) != 1 || got["error"] != c.body {
t.Fatalf("body %s, want only error %q", rec.Body.String(), c.body)
}
}
})
}
}

func TestRequireRoleAdmitsARecoverySessionWhenAllowed(t *testing.T) {
api := newFakeAPI(t)
a := newTestAdapter(t, api, func(o *Options) { o.AllowRecoverySessions = true })
token := signRS256(t, map[string]any{"sub": "u1", "typ": "access", "roles": []string{"admin"}, "recovery": true, "iss": api.server.URL, "aud": api.server.URL})
h := a.RequireRole("admin")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, map[string]any{})
}))
if rec := call(h, withHeader("Authorization", "Bearer "+token)); rec.Code != 200 {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
}

func TestRequireRolePanicsWithoutARole(t *testing.T) {
a := newTestAdapter(t, newFakeAPI(t))
for name, roles := range map[string][]string{"none": nil, "empty": {""}, "blank": {"admin", " "}} {
t.Run(name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
a.RequireRole(roles...)
})
}
}

func TestTheOrganizationComesFromTheVerifiedToken(t *testing.T) {
api := newFakeAPI(t)
a := newTestAdapter(t, api)
token := func(claims map[string]any) string {
body := map[string]any{"sub": "u1", "typ": "access", "iss": api.server.URL, "aud": api.server.URL}
for k, v := range claims {
body[k] = v
}
return signRS256(t, body)
}

cases := []struct {
name string
setup func(*http.Request)
org string
}{
{"cookie", withCookie(signedCookie(t, "seamless-access", map[string]any{
"sub": "u1", "token": token(map[string]any{"org_id": "org-1"}), "organizationId": "org-cookie",
})), "org-1"},
{"cookie without org_id", withCookie(signedCookie(t, "seamless-access", map[string]any{
"sub": "u1", "token": token(nil), "organizationId": "org-cookie",
})), ""},
{"bearer", withHeader("Authorization", "Bearer "+token(map[string]any{"org_id": "org-1"})), "org-1"},
{"bearer without org_id", withHeader("Authorization", "Bearer "+token(nil)), ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/me", nil)
c.setup(req)
user, err := a.Authenticate(req)
if err != nil {
t.Fatal(err)
}
if user.OrganizationID != c.org {
t.Fatalf("organization %q, want %q", user.OrganizationID, c.org)
}
})
}
}
3 changes: 3 additions & 0 deletions refresh.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ func (a *Adapter) refreshRoute(r *http.Request, bearer bool) result {
// refuses, so a browser is never left holding a session it asked to end.
func (a *Adapter) logoutRoute(r *http.Request, bearer bool, path string) result {
clears := []string{a.opts.AccessCookieName, a.opts.RegistrationCookieName, a.opts.RefreshCookieName}
if a.opts.PreAuthCookieName != a.opts.RegistrationCookieName {
clears = append(clears, a.opts.PreAuthCookieName)
}

cred, rejected := a.credentialFor(r, "access", bearer)
if rejected != nil {
Expand Down
Loading