diff --git a/README.md b/README.md index 38d3f5c..2ee6daf 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,62 @@ # libauth -LibAuth for Go - The modern authentication framework that feels as light as a library. + +LibAuth for Go - A modern authentication framework that feels as light as a library. + +[![godoc_button]][godoc] + +[godoc]: https://pkg.go.dev/git.rootprojects.org/root/libauth?tab=versions +[godoc_button]: https://godoc.org/git.rootprojects.org/root/libauth?status.svg + +## Example Usage + +How to verify a valid, trusted token as `chi` middleware: + +```go +package main + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + + "git.rootprojects.org/root/keypairs/keyfetch" + "git.rootprojects.org/root/libauth" + "git.rootprojects.org/root/libauth/chiauth" +) + +func main() { + r := chi.NewRouter() + + whitelist, err := keyfetch.NewWhitelist([]string{"https://accounts.google.com"}) + if nil != err { + panic(err) + } + tokenVerifier := chiauth.NewTokenVerifier(chiauth.VerificationParams{ + Issuers: whitelist, + Optional: false, + }) + r.Use(tokenVerifier) + + r.Post("/api/users/profile", func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + jws, ok := ctx.Value(chiauth.JWSKey).(*libauth.JWS) + if !ok || !jws.Trusted { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + } + + userID := jws.Claims["sub"].(string) + // ... + }) + + // ... +} +``` + +How to pass an auth token: + +```bash +curl -X POST http://localhost:3000/api/users/profile \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + --raw-data '{ "foo": "bar" }' +``` diff --git a/chiauth/chiauth.go b/chiauth/chiauth.go new file mode 100644 index 0000000..57da080 --- /dev/null +++ b/chiauth/chiauth.go @@ -0,0 +1,92 @@ +package chiauth + +import ( + "context" + "log" + "net/http" + "strings" + + "git.rootprojects.org/root/keypairs/keyfetch" + "git.rootprojects.org/root/libauth" +) + +type ctxKey string + +// JWSKey is used to get the InspectableToken from http.Request.Context().Value(chiauth.JWSKey) +var JWSKey = ctxKey("jws") + +// VerificationParams specify the Issuer and whether or not the token is Optional (if provided, it must pass verification) +type VerificationParams struct { + Issuers keyfetch.Whitelist + Optional bool + /* + ExpLeeway int + NbfLeeway int + SelfSigned bool + PubKey keypairs.PublicKey + */ +} + +// NewTokenVerifier returns a token-verifying middleware +// +// tokenVerifier := chiauth.NewTokenVerifier(chiauth.VerificationParams{ +// Issuers: keyfetch.Whitelist([]string{"https://accounts.google.com"}), +// Optional: false, +// }) +// r.Use(tokenVerifier) +// +// r.Post("/api/users/profile", func(w http.ResponseWriter, r *http.Request) { +// ctx := r.Context() +// jws, ok := ctx.Value(chiauth.JWSKey).(*libauth.JWS) +// }) +// +func NewTokenVerifier(opts VerificationParams) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // just setting a default, other handlers can change this + + token := r.Header.Get("Authorization") + log.Printf("%s %s %s\n", r.Method, r.URL.Path, token) + + if "" == token { + if opts.Optional { + next.ServeHTTP(w, r) + return + } + + http.Error( + w, + "Bad Format: missing Authorization header and 'access_token' query", + http.StatusBadRequest, + ) + return + } + + parts := strings.Split(token, " ") + if 2 != len(parts) { + http.Error( + w, + "Bad Format: expected Authorization header to be in the format of 'Bearer '", + http.StatusBadRequest, + ) + return + } + token = parts[1] + + inspected, err := libauth.VerifyJWT(token, opts.Issuers, r) + if nil != err { + w.WriteHeader(http.StatusBadRequest) + errmsg := "Invalid Token: " + err.Error() + w.Write([]byte(errmsg)) + return + } + if !inspected.Trusted { + http.Error(w, "Bad Token Signature", http.StatusBadRequest) + return + } + + ctx := context.WithValue(r.Context(), JWSKey, inspected) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/libauth.go b/libauth.go new file mode 100644 index 0000000..aa92454 --- /dev/null +++ b/libauth.go @@ -0,0 +1,84 @@ +package libauth + +import ( + "fmt" + "net/http" + "strings" + + "git.rootprojects.org/root/keypairs" + "git.rootprojects.org/root/keypairs/keyfetch" +) + +// JWS is keypairs.JWS with added debugging information +type JWS struct { + keypairs.JWS + + Trusted bool `json:"trusted"` + Errors []error `json:"errors,omitempty"` +} + +// VerifyJWT will return a verified InspectableToken if possible, or otherwise as much detail as possible, possibly including an InspectableToken with failed verification. +func VerifyJWT(jwt string, issuers keyfetch.Whitelist, r *http.Request) (*JWS, error) { + jws := keypairs.JWTToJWS(jwt) + if nil == jws { + return nil, fmt.Errorf("Bad Request: malformed Authorization header") + } + + if err := jws.DecodeComponents(); nil != err { + return &JWS{ + *jws, + false, + []error{err}, + }, err + } + + return VerifyJWS(jws, issuers, r) +} + +// VerifyJWS takes a fully decoded JWS and will return a verified InspectableToken if possible, or otherwise as much detail as possible, possibly including an InspectableToken with failed verification. +func VerifyJWS(jws *keypairs.JWS, issuers keyfetch.Whitelist, r *http.Request) (*JWS, error) { + var pub keypairs.PublicKey + kid, kidOK := jws.Header["kid"].(string) + iss, issOK := jws.Claims["iss"].(string) + + _, jwkOK := jws.Header["jwk"] + if jwkOK { + if !kidOK || 0 == len(kid) { + //errs = append(errs, "must have either header.kid or header.jwk") + return nil, fmt.Errorf("Bad Request: missing 'kid' identifier") + } else if !issOK || 0 == len(iss) { + //errs = append(errs, "payload.iss must exist to complement header.kid") + return nil, fmt.Errorf("Bad Request: payload.iss must exist to complement header.kid") + } else { + // TODO beware domain fronting, we should set domain statically + // See https://pkg.go.dev/git.rootprojects.org/root/keypairs@v0.6.2/keyfetch + // (Caddy does protect against Domain-Fronting by default: + // https://github.com/caddyserver/caddy/issues/2500) + if !issuers.IsTrustedIssuer(iss, r) { + return nil, fmt.Errorf("Bad Request: 'iss' is not a trusted issuer") + } + } + var err error + pub, err = keyfetch.OIDCJWK(kid, iss) + if nil != err { + return nil, fmt.Errorf("Bad Request: 'kid' could not be matched to a known public key") + } + } else { + return nil, fmt.Errorf("Bad Request: self-signed tokens with 'jwk' are not supported") + } + + errs := keypairs.VerifyClaims(pub, jws) + if 0 != len(errs) { + strs := []string{} + for _, err := range errs { + strs = append(strs, err.Error()) + } + return nil, fmt.Errorf("invalid jwt:\n%s", strings.Join(strs, "\n\t")) + } + + return &JWS{ + JWS: *jws, + Trusted: true, + Errors: nil, + }, nil +}