Skip to content
This repository was archived by the owner on Jun 12, 2024. It is now read-only.

Commit edb11ec

Browse files
authored
Unify middleware and tokens claim model (#98)
**What** - use the authorization.Claims model in the tokens creator - Expand claims validation tests - Expand authorization middleware test cases Signed-off-by: Lucas Roesler <[email protected]>
1 parent ae160ae commit edb11ec

File tree

9 files changed

+347
-166
lines changed

9 files changed

+347
-166
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Contiamo Go base
2-
[![GoDoc](https://godoc.org/github.com/contiamo/go-base/v2?status.png)](https://godoc.org/github.com/contiamo/go-base/v2)
3-
[![Go Report Card](https://goreportcard.com/badge/github.com/contiamo/go-base/v2)](https://goreportcard.com/report/github.com/contiamo/go-base/v2)
2+
[![GoDoc](https://godoc.org/github.com/contiamo/go-base/v3?status.png)](https://godoc.org/github.com/contiamo/go-base/v3)
3+
[![Go Report Card](https://goreportcard.com/badge/github.com/contiamo/go-base/v3)](https://goreportcard.com/report/github.com/contiamo/go-base/v3)
44
[![CircleCI](https://circleci.com/gh/contiamo/go-base/tree/master.svg?style=svg)](https://circleci.com/gh/contiamo/go-base/tree/master)
55

66
This module contains common packages for Contiamo projects written in Go. Once, some of the projects introduce a pattern/approach which is worth re-using it's getting added here.

pkg/db/migrations/doc.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Additionally, you will need to setup the assets_dev.go and migrations.go files.
3838
import (
3939
"net/http"
4040
41-
"github.com/contiamo/go-base/v2/pkg/fileutils/union"
41+
"github.com/contiamo/go-base/v3/pkg/fileutils/union"
4242
)
4343
4444
// Assets contains the static SQL file assets for setup and migrations
@@ -54,8 +54,8 @@ and then
5454
package db
5555
5656
import (
57-
"github.com/contiamo/go-base/v2/pkg/db/migrations"
58-
"github.com/contiamo/go-base/v2/pkg/queue/postgres"
57+
"github.com/contiamo/go-base/v3/pkg/db/migrations"
58+
"github.com/contiamo/go-base/v3/pkg/queue/postgres"
5959
"github.com/contiamo/app/pkg/config"
6060
)
6161

pkg/http/middlewares/authorization/claims.go

Lines changed: 128 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import (
44
"context"
55
"encoding/json"
66
"net/http"
7+
"time"
78

89
"github.com/contiamo/jwt"
910
uuid "github.com/satori/go.uuid"
11+
"github.com/sirupsen/logrus"
1012
)
1113

1214
// authContextKey is an unexported type for keys defined in middleware.
@@ -28,51 +30,85 @@ var (
2830
}
2931
)
3032

31-
// Claims represents the expected claims that should be in a JWT sent to labs
32-
//
33-
// The IDP defined the token as
34-
// type RequestToken struct {
35-
// ID string `protobuf:"bytes,1,opt,name=ID,json=id,proto3" json:"ID,omitempty"`
36-
// IssuedAt float64 `protobuf:"fixed64,2,opt,name=IssuedAt,json=iat,proto3" json:"IssuedAt,omitempty"`
37-
// NotBefore float64 `protobuf:"fixed64,3,opt,name=NotBefore,json=nbf,proto3" json:"NotBefore,omitempty"`
38-
// Expires float64 `protobuf:"fixed64,4,opt,name=Expires,json=exp,proto3" json:"Expires,omitempty"`
39-
// Issuer string `protobuf:"bytes,5,opt,name=Issuer,json=iss,proto3" json:"Issuer,omitempty"`
40-
// UserID string `protobuf:"bytes,6,opt,name=UserID,json=sub,proto3" json:"UserID,omitempty"`
41-
// UserName string `protobuf:"bytes,7,opt,name=UserName,json=name,proto3" json:"UserName,omitempty"`
42-
// TenantID string `protobuf:"bytes,8,opt,name=TenantID,json=tenantID,proto3" json:"TenantID,omitempty"`
43-
// Email string `protobuf:"bytes,9,opt,name=Email,json=email,proto3" json:"Email,omitempty"`
44-
// RealmIDs []string `protobuf:"bytes,10,rep,name=RealmIDs,json=realmIDs,proto3" json:"RealmIDs,omitempty"`
45-
// GroupIDs []string `protobuf:"bytes,11,rep,name=GroupIDs,json=groupIDs,proto3" json:"GroupIDs,omitempty"`
46-
// ResourceTokenIDs []string `protobuf:"bytes,12,rep,name=ResourceTokenIDs,json=resourceTokenIDs,proto3" json:"ResourceTokenIDs,omitempty"`
47-
// AllowedIPs []string `protobuf:"bytes,13,rep,name=AllowedIPs,json=allowedIPs,proto3" json:"AllowedIPs,omitempty"`
48-
// IsTenantAdmin bool `protobuf:"varint,14,opt,name=IsTenantAdmin,json=isTenantAdmin,proto3" json:"IsTenantAdmin,omitempty"`
49-
// AdminRealmIDs []string `protobuf:"bytes,15,rep,name=AdminRealmIDs,json=adminRealmIDs,proto3" json:"AdminRealmIDs,omitempty"`
50-
// AuthenticationMethodReferences []string `protobuf:"bytes,16,rep,name=AuthenticationMethodReferences,json=amr,proto3" json:"AuthenticationMethodReferences,omitempty"`
51-
// }
33+
// Claims represents the expected claims that should be in JWT claims of an X-Request-Token
5234
type Claims struct {
53-
ID string `json:"id"`
54-
IssuedAt Timestamp `json:"iat"`
55-
NotBefore Timestamp `json:"nbf"`
56-
Expires Timestamp `json:"exp"`
57-
Issuer string `json:"iss"`
58-
UserID string `json:"sub"`
59-
UserName string `json:"name"`
60-
TenantID string `json:"tenantID"`
61-
Email string `json:"email"`
62-
RealmIDs []string `json:"realmIDs"`
63-
GroupIDs []string `json:"groupIDs"`
64-
ResourceTokenIDs []string `json:"resourceTokenIDs"`
65-
AllowedIPs []string `json:"allowedIPs"`
66-
IsTenantAdmin bool `json:"isTenantAdmin"`
67-
AdminRealmIDs []string `json:"adminRealmIDs"`
68-
SourceToken string `json:"-"`
69-
AuthenticationMethodReferences []string `json:"amr"`
35+
// standard oidc claims
36+
ID string `json:"id"`
37+
Issuer string `json:"iss"`
38+
IssuedAt Timestamp `json:"iat"`
39+
NotBefore Timestamp `json:"nbf"`
40+
Expires Timestamp `json:"exp"`
41+
Audience string `json:"aud,omitempty"`
42+
43+
UserID string `json:"sub"`
44+
UserName string `json:"name"`
45+
Email string `json:"email"`
46+
47+
// Contiamo specific claims
48+
TenantID string `json:"tenantID"`
49+
RealmIDs []string `json:"realmIDs"`
50+
GroupIDs []string `json:"groupIDs"`
51+
AllowedIPs []string `json:"allowedIPs"`
52+
IsTenantAdmin bool `json:"isTenantAdmin"`
53+
AdminRealmIDs []string `json:"adminRealmIDs"`
54+
55+
AuthenticationMethodReferences []string `json:"amr"`
56+
// AuthorizedParty is used to indicate that the request is authorizing as a
57+
// service request, giving it super-admin privileges to completely any request.
58+
// This replaces the "project admin" behavior of the current tokens.
59+
AuthorizedParty string `json:"azp,omitempty"`
60+
61+
// SourceToken is for internal usage only
62+
SourceToken string `json:"-"`
7063
}
7164

7265
// Valid tests if the Claims object contains the minimal required information
7366
// to be used for authorization checks.
67+
//
68+
// Deprecated: Use the Validate method to get a precise error message. This
69+
// method remains for backward compatibility.
7470
func (a *Claims) Valid() bool {
75-
return a.UserID != "" || len(a.ResourceTokenIDs) > 0
71+
72+
return a.Validate() == nil
73+
}
74+
75+
// Validate verifies the token claims.
76+
func (a Claims) Validate() (err error) {
77+
defer func() {
78+
logrus.WithError(err).Error("claims validation error")
79+
}()
80+
81+
now := TimeFunc()
82+
83+
// this validation is specific to contiamo
84+
if a.UserID == "" {
85+
return ErrMissingSub
86+
}
87+
88+
// the middleware parsing will generally run this validation, but
89+
// adding it here marks the exp as a required claim
90+
if !a.VerifyExpiresAt(now, true) {
91+
return ErrExpiration
92+
}
93+
94+
// the middleware parsing will generally run this validation, but
95+
// adding it here marks the nbf as a required claim
96+
if !a.VerifyNotBefore(now, true) {
97+
return ErrTooEarly
98+
}
99+
100+
// the middleware parsing will generally run this validation, but
101+
// adding it here marks the iat as a required claim
102+
if !a.VerifyIssuedAt(now, true) {
103+
return ErrTooSoon
104+
}
105+
106+
// this validation is specific to contiamo
107+
if !a.VerifyAuthorizedParty() {
108+
return ErrInvalidParty
109+
}
110+
111+
return nil
76112
}
77113

78114
// FromClaimsMap loads the claim information from a jwt.Claims object, this is a simple
@@ -112,10 +148,62 @@ func (a *Claims) ToJWT(privateKey interface{}) (string, error) {
112148
func (a *Claims) Entities() (entities []string) {
113149
entities = append(entities, a.UserID)
114150
entities = append(entities, a.GroupIDs...)
115-
entities = append(entities, a.ResourceTokenIDs...)
116151
return entities
117152
}
118153

154+
// VerifyAudience compares the aud claim against cmp.
155+
func (a Claims) VerifyAudience(cmp string, required bool) bool {
156+
if a.Audience == "" {
157+
return !required
158+
}
159+
160+
return a.Audience == cmp
161+
}
162+
163+
// VerifyExpiresAt compares the exp claim against the cmp time.
164+
func (a Claims) VerifyExpiresAt(cmp time.Time, required bool) bool {
165+
if a.Expires.time.IsZero() {
166+
return !required
167+
}
168+
169+
return cmp.Before(a.Expires.time)
170+
}
171+
172+
// VerifyNotBefore compares the nbf claim against the cmp time.
173+
func (a Claims) VerifyNotBefore(cmp time.Time, required bool) bool {
174+
if a.NotBefore.time.IsZero() {
175+
return !required
176+
}
177+
178+
return cmp.After(a.NotBefore.time) || cmp.Equal(a.NotBefore.time)
179+
}
180+
181+
// VerifyIssuedAt compares the iat claim against the cmp time.
182+
func (a Claims) VerifyIssuedAt(cmp time.Time, required bool) bool {
183+
if a.IssuedAt.time.IsZero() {
184+
return !required
185+
}
186+
187+
return cmp.After(a.IssuedAt.time) || cmp.Equal(a.IssuedAt.time)
188+
}
189+
190+
// VerifyIssuer compares the iss claim against cmp.
191+
func (a Claims) VerifyIssuer(cmp string, required bool) bool {
192+
if a.Issuer == "" {
193+
return !required
194+
}
195+
196+
return a.Issuer == cmp
197+
}
198+
199+
// VerifyAuthorizedParty verify that azp matches the iss value, if set.
200+
func (a Claims) VerifyAuthorizedParty() bool {
201+
if a.AuthorizedParty == "" {
202+
return true
203+
}
204+
return a.VerifyIssuer(a.AuthorizedParty, true)
205+
}
206+
119207
// GetClaims retrieves the Claims object from the request context
120208
func GetClaims(r *http.Request) (Claims, bool) {
121209
claims, ok := GetClaimsFromCtx(r.Context())

pkg/http/middlewares/authorization/claims_test.go

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,56 +4,80 @@ import (
44
"net/http"
55
"net/http/httptest"
66
"testing"
7+
"time"
78

89
"github.com/stretchr/testify/require"
910
)
1011

1112
func Test_Valid(t *testing.T) {
13+
// Freeze time
14+
now := time.Now()
15+
TimeFunc = func() time.Time {
16+
return now
17+
}
18+
1219
cases := []struct {
1320
name string
1421
claims Claims
15-
valid bool
22+
err error
1623
}{
1724
{
1825
name: "Empty claims should not be valid",
1926
claims: Claims{},
20-
valid: false,
27+
err: ErrMissingSub,
28+
},
29+
{
30+
name: "Invalid if used before the iat",
31+
claims: Claims{
32+
UserID: "this is a test id",
33+
IssuedAt: FromTime(now.Add(2 * time.Second)),
34+
Expires: FromTime(now.Add(5 * time.Second)),
35+
NotBefore: FromTime(now.Add(-1 * time.Second)),
36+
},
37+
err: ErrTooSoon,
2138
},
2239
{
23-
name: "If the user ID is set it's valid",
40+
name: "Invalid if used before the nbf",
2441
claims: Claims{
25-
UserID: "this is a test id",
42+
UserID: "this is a test id",
43+
IssuedAt: FromTime(now.Add(-1 * time.Second)),
44+
Expires: FromTime(now.Add(5 * time.Second)),
45+
NotBefore: FromTime(now.Add(1 * time.Second)),
2646
},
27-
valid: true,
47+
err: ErrTooEarly,
2848
},
2949
{
30-
name: "If the resource token ID is set it's valid",
50+
name: "Invalid if used after exp",
3151
claims: Claims{
32-
ResourceTokenIDs: []string{"abc123"},
52+
UserID: "this is a test id",
53+
IssuedAt: FromTime(now.Add(-5 * time.Second)),
54+
Expires: FromTime(now.Add(-1 * time.Second)),
55+
NotBefore: FromTime(now.Add(-5 * time.Second)),
3356
},
34-
valid: true,
57+
err: ErrExpiration,
3558
},
3659
{
37-
name: "If the user ID and resource token ID are set it's valid",
60+
name: "minimally valid claims requires sub, iat, exp, and nbf",
3861
claims: Claims{
39-
UserID: "this is a test id",
40-
ResourceTokenIDs: []string{"abc123"},
62+
UserID: "this is a test id",
63+
IssuedAt: FromTime(now.Add(-1 * time.Second)),
64+
Expires: FromTime(now.Add(time.Second)),
65+
NotBefore: FromTime(now.Add(-1 * time.Second)),
4166
},
42-
valid: true,
4367
},
4468
}
4569
for _, tc := range cases {
4670
t.Run(tc.name, func(t *testing.T) {
47-
require.Equal(t, tc.valid, tc.claims.Valid())
71+
require.Equal(t, tc.claims.Validate(), tc.err)
72+
require.Equal(t, tc.err == nil, tc.claims.Valid())
4873
})
4974
}
5075
}
5176

5277
func Test_SetGetClaims(t *testing.T) {
5378
r := httptest.NewRequest(http.MethodGet, "/", nil)
5479
claims := Claims{
55-
UserID: "my user ID",
56-
ResourceTokenIDs: []string{"some", "other", "resource", "token"},
80+
UserID: "my user ID",
5781
}
5882
r = SetClaims(r, claims)
5983
extractedClaims, ok := GetClaims(r)
@@ -63,9 +87,8 @@ func Test_SetGetClaims(t *testing.T) {
6387

6488
func Test_Entities(t *testing.T) {
6589
claims := Claims{
66-
UserID: "user-id",
67-
GroupIDs: []string{"group-first", "group-second", "group-third"},
68-
ResourceTokenIDs: []string{"res-first", "res-second"},
90+
UserID: "user-id",
91+
GroupIDs: []string{"group-first", "group-second", "group-third"},
6992
}
7093

7194
require.Equal(
@@ -75,18 +98,15 @@ func Test_Entities(t *testing.T) {
7598
"group-first",
7699
"group-second",
77100
"group-third",
78-
"res-first",
79-
"res-second",
80101
},
81102
claims.Entities(),
82103
)
83104
}
84105

85106
func Test_FromToClaims(t *testing.T) {
86107
claims := Claims{
87-
UserID: "user-id",
88-
GroupIDs: []string{"group-first", "group-second", "group-third"},
89-
ResourceTokenIDs: []string{"res-first", "res-second"},
108+
UserID: "user-id",
109+
GroupIDs: []string{"group-first", "group-second", "group-third"},
90110
}
91111
jwtClaims, err := claims.ToClaims()
92112
require.NoError(t, err)
@@ -95,15 +115,10 @@ func Test_FromToClaims(t *testing.T) {
95115
[]interface{}{"group-first", "group-second", "group-third"},
96116
jwtClaims["groupIDs"],
97117
)
98-
require.Equal(t,
99-
[]interface{}{"res-first", "res-second"},
100-
jwtClaims["resourceTokenIDs"],
101-
)
102118

103119
// edit the exported map and try to import its values
104120
jwtClaims["sub"] = "new-user-id"
105121
jwtClaims["groupIDs"] = []interface{}{"new-group-first", "new-group-second"}
106-
jwtClaims["resourceTokenIDs"] = []interface{}{"new-res-first", "new-res-second"}
107122

108123
err = claims.FromClaimsMap(jwtClaims)
109124
require.NoError(t, err)
@@ -113,9 +128,4 @@ func Test_FromToClaims(t *testing.T) {
113128
[]string{"new-group-first", "new-group-second"},
114129
claims.GroupIDs,
115130
)
116-
require.Equal(
117-
t,
118-
[]string{"new-res-first", "new-res-second"},
119-
claims.ResourceTokenIDs,
120-
)
121131
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package authorization
2+
3+
import "github.com/pkg/errors"
4+
5+
// Validation error constants
6+
var (
7+
ErrMissingSub = errors.New("sub is required")
8+
ErrExpiration = errors.New("invalid exp")
9+
ErrTooEarly = errors.New("token is not valid yet")
10+
ErrTooSoon = errors.New("token used before issued")
11+
ErrInvalidParty = errors.New("invalid authorized party")
12+
)

0 commit comments

Comments
 (0)