263 lines
8.4 KiB
Go
263 lines
8.4 KiB
Go
package oauth2
|
|
|
|
import (
|
|
"context"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"time"
|
|
|
|
gojwt "github.com/dgrijalva/jwt-go"
|
|
)
|
|
|
|
// Ensure JWT conforms to the Tokenizer interface
|
|
var _ Tokenizer = &JWT{}
|
|
|
|
// JWT represents a javascript web token that can be validated or marshaled into string.
|
|
type JWT struct {
|
|
Secret string
|
|
Jwksurl string
|
|
Now func() time.Time
|
|
}
|
|
|
|
// NewJWT creates a new JWT using time.Now
|
|
// secret is used for signing and validating signatures (HS256/HMAC)
|
|
// jwksurl is used for validating RS256 signatures.
|
|
func NewJWT(secret string, jwksurl string) *JWT {
|
|
return &JWT{
|
|
Secret: secret,
|
|
Jwksurl: jwksurl,
|
|
Now: DefaultNowTime,
|
|
}
|
|
}
|
|
|
|
// Ensure Claims implements the jwt.Claims interface
|
|
var _ gojwt.Claims = &Claims{}
|
|
|
|
// Claims extends jwt.StandardClaims' Valid to make sure claims has a subject.
|
|
type Claims struct {
|
|
gojwt.StandardClaims
|
|
// We were unable to find a standard claim at https://www.iana.org/assignments/jwt/jwt.xhtml
|
|
// that felt appropriate for Organization. As a result, we added a custom `org` field.
|
|
Organization string `json:"org,omitempty"`
|
|
// We were unable to find a standard claim at https://www.iana.org/assignments/jwt/jwt.xhtml
|
|
// that felt appropriate for a users Group(s). As a result we added a custom `grp` field.
|
|
// Multiple groups may be specified by comma delimiting the various group.
|
|
//
|
|
// The singlular `grp` was chosen over the `grps` to keep consistent with the JWT naming
|
|
// convention (it is common for singlularly named values to actually be arrays, see `given_name`,
|
|
// `family_name`, and `middle_name` in the iana link provided above). I should add the discalimer
|
|
// I'm currently sick, so this thought process might be off.
|
|
Group string `json:"grp,omitempty"`
|
|
}
|
|
|
|
// Valid adds an empty subject test to the StandardClaims checks.
|
|
func (c *Claims) Valid() error {
|
|
if err := c.StandardClaims.Valid(); err != nil {
|
|
return err
|
|
} else if c.StandardClaims.Subject == "" {
|
|
return fmt.Errorf("claim has no subject")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidPrincipal checks if the jwtToken is signed correctly and validates with Claims. lifespan is the
|
|
// maximum valid lifetime of a token. If the lifespan is 0 then the auth lifespan duration is not checked.
|
|
func (j *JWT) ValidPrincipal(ctx context.Context, jwtToken Token, lifespan time.Duration) (Principal, error) {
|
|
gojwt.TimeFunc = j.Now
|
|
|
|
// Check for expected signing method.
|
|
alg := j.KeyFunc
|
|
|
|
return j.ValidClaims(jwtToken, lifespan, alg)
|
|
}
|
|
|
|
// KeyFunc verifies HMAC or RSA/RS256 signatures
|
|
func (j *JWT) KeyFunc(token *gojwt.Token) (interface{}, error) {
|
|
if _, ok := token.Method.(*gojwt.SigningMethodHMAC); ok {
|
|
return []byte(j.Secret), nil
|
|
} else if _, ok := token.Method.(*gojwt.SigningMethodRSA); ok {
|
|
return j.KeyFuncRS256(token)
|
|
}
|
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
|
}
|
|
|
|
// For the id_token, the recommended signature algorithm is RS256, which
|
|
// means we need to verify the token against a public key. This public key
|
|
// is available from the key discovery service in JSON Web Key (JWK).
|
|
// JWK is specified in RFC 7517.
|
|
//
|
|
// The location of the key discovery service (JWKSURL) is published in the
|
|
// OpenID Provider Configuration Information at /.well-known/openid-configuration
|
|
// implements rfc7517 section 4.7 "x5c" (X.509 Certificate Chain) Parameter
|
|
|
|
// JWK defines a JSON Web KEy nested struct
|
|
type JWK struct {
|
|
Kty string `json:"kty"`
|
|
Use string `json:"use"`
|
|
Alg string `json:"alg"`
|
|
Kid string `json:"kid"`
|
|
X5t string `json:"x5t"`
|
|
N string `json:"n"`
|
|
E string `json:"e"`
|
|
X5c []string `json:"x5c"`
|
|
}
|
|
|
|
// JWKS defines a JKW[]
|
|
type JWKS struct {
|
|
Keys []JWK `json:"keys"`
|
|
}
|
|
|
|
// KeyFuncRS256 verifies RS256 signed JWT tokens, it looks up the signing key in the key discovery service
|
|
func (j *JWT) KeyFuncRS256(token *gojwt.Token) (interface{}, error) {
|
|
// Don't forget to validate the alg is what you expect:
|
|
if _, ok := token.Method.(*gojwt.SigningMethodRSA); !ok {
|
|
return nil, fmt.Errorf("Unsupported signing method: %v", token.Header["alg"])
|
|
}
|
|
|
|
// read JWKS document from key discovery service
|
|
if j.Jwksurl == "" {
|
|
return nil, fmt.Errorf("JWKSURL not specified, cannot validate RS256 signature")
|
|
}
|
|
|
|
rr, err := http.Get(j.Jwksurl)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rr.Body.Close()
|
|
body, err := ioutil.ReadAll(rr.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// parse json to struct
|
|
var jwks JWKS
|
|
if err := json.Unmarshal([]byte(body), &jwks); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// extract cert when kid and alg match
|
|
var certPkix []byte
|
|
for _, jwk := range jwks.Keys {
|
|
if token.Header["kid"] == jwk.Kid {
|
|
// FIXME: optionally walk the key chain, see rfc7517 section 4.7
|
|
certPkix, err = base64.StdEncoding.DecodeString(jwk.X5c[0])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("base64 decode error for JWK kid %v", token.Header["kid"])
|
|
}
|
|
}
|
|
}
|
|
if certPkix == nil {
|
|
return nil, fmt.Errorf("no signing key found for kid %v", token.Header["kid"])
|
|
}
|
|
|
|
// parse certificate (from PKIX format) and return signing key
|
|
cert, err := x509.ParseCertificate(certPkix)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return cert.PublicKey, nil
|
|
}
|
|
|
|
// ValidClaims validates a token with StandardClaims
|
|
func (j *JWT) ValidClaims(jwtToken Token, lifespan time.Duration, alg gojwt.Keyfunc) (Principal, error) {
|
|
// 1. Checks for expired tokens
|
|
// 2. Checks if time is after the issued at
|
|
// 3. Check if time is after not before (nbf)
|
|
// 4. Check if subject is not empty
|
|
// 5. Check if duration less than auth lifespan
|
|
token, err := gojwt.ParseWithClaims(string(jwtToken), &Claims{}, alg)
|
|
if err != nil {
|
|
return Principal{}, err
|
|
// at time of this writing and researching the docs, token.Valid seems to be always true
|
|
} else if !token.Valid {
|
|
return Principal{}, err
|
|
}
|
|
|
|
// at time of this writing and researching the docs, there will always be claims
|
|
claims, ok := token.Claims.(*Claims)
|
|
if !ok {
|
|
return Principal{}, fmt.Errorf("unable to convert claims to standard claims")
|
|
}
|
|
|
|
exp := time.Unix(claims.ExpiresAt, 0)
|
|
iat := time.Unix(claims.IssuedAt, 0)
|
|
|
|
// If the duration of the claim is longer than the auth lifespan then this is
|
|
// an invalid claim because server assumes that lifespan is the maximum possible
|
|
// duration. However, a lifespan of zero means that the duration comparison
|
|
// against the auth duration is not needed.
|
|
if lifespan > 0 && exp.Sub(iat) > lifespan {
|
|
return Principal{}, fmt.Errorf("claims duration is different from auth lifespan")
|
|
}
|
|
|
|
return Principal{
|
|
Subject: claims.Subject,
|
|
Issuer: claims.Issuer,
|
|
Organization: claims.Organization,
|
|
Group: claims.Group,
|
|
ExpiresAt: exp,
|
|
IssuedAt: iat,
|
|
}, nil
|
|
}
|
|
|
|
// GetClaims extracts claims from id_token
|
|
func (j *JWT) GetClaims(tokenString string) (gojwt.MapClaims, error) {
|
|
var claims gojwt.MapClaims
|
|
|
|
gojwt.TimeFunc = j.Now
|
|
token, err := gojwt.Parse(tokenString, j.KeyFunc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !token.Valid {
|
|
return nil, fmt.Errorf("token is not valid")
|
|
}
|
|
|
|
claims, ok := token.Claims.(gojwt.MapClaims)
|
|
if !ok {
|
|
return nil, fmt.Errorf("token has no claims")
|
|
}
|
|
|
|
return claims, nil
|
|
}
|
|
|
|
// Create creates a signed JWT token from user that expires at Principal's ExpireAt time.
|
|
func (j *JWT) Create(ctx context.Context, user Principal) (Token, error) {
|
|
// Create a new token object, specifying signing method and the claims
|
|
// you would like it to contain.
|
|
claims := &Claims{
|
|
StandardClaims: gojwt.StandardClaims{
|
|
Subject: user.Subject,
|
|
Issuer: user.Issuer,
|
|
ExpiresAt: user.ExpiresAt.Unix(),
|
|
IssuedAt: user.IssuedAt.Unix(),
|
|
NotBefore: user.IssuedAt.Unix(),
|
|
},
|
|
Organization: user.Organization,
|
|
Group: user.Group,
|
|
}
|
|
token := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims)
|
|
// Sign and get the complete encoded token as a string using the secret
|
|
t, err := token.SignedString([]byte(j.Secret))
|
|
// this will only fail if the JSON can't be encoded correctly
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return Token(t), nil
|
|
}
|
|
|
|
// ExtendedPrincipal sets the expires at to be the current time plus the extention into the future
|
|
func (j *JWT) ExtendedPrincipal(ctx context.Context, principal Principal, extension time.Duration) (Principal, error) {
|
|
// Extend the time of expiration. Do not change IssuedAt as the
|
|
// lifetime of the token is extended, but, NOT the original time
|
|
// of issue. This is used to enforce a maximum lifetime of a token
|
|
principal.ExpiresAt = j.Now().Add(extension)
|
|
return principal, nil
|
|
}
|