diff --git a/.gitignore b/.gitignore index 9a3a8d8..8275cf7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +/go-mockid + # ---> Go # Binaries for programs and plugins *.exe diff --git a/mockid.go b/mockid.go new file mode 100644 index 0000000..0436c66 --- /dev/null +++ b/mockid.go @@ -0,0 +1,187 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "log" + "math/big" + "net/http" + "os" + "strconv" + "time" +) + +type PrivateJWK struct { + PublicJWK + D string `json:"d"` +} +type PublicJWK struct { + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +func main() { + done := make(chan bool) + var port int + var host string + + jwkm := map[string]string{ + "crv": "P-256", + "d": "GYAwlBHc2mPsj1lp315HbYOmKNJ7esmO3JAkZVn9nJs", + "x": "ToL2HppsTESXQKvp7ED6NMgV4YnwbMeONexNry3KDNQ", + "y": "Tt6Q3rxU37KAinUV9PLMlwosNy1t3Bf2VDg5q955AGc", + } + jwk := &PrivateJWK{ + PublicJWK: PublicJWK{ + Crv: jwkm["crv"], + X: jwkm["x"], + Y: jwkm["y"], + }, + D: jwkm["d"], + } + priv := parseKey(jwk) + pub := &priv.PublicKey + thumbprint := thumbprintKey(pub) + + portFlag := flag.Int("port", 0, "Port on which the HTTP server should run") + urlFlag := flag.String("url", "", "Outward-facing address, such as https://example.com") + flag.Parse() + + if nil != portFlag && *portFlag > 0 { + port = *portFlag + } else { + portStr := os.Getenv("PORT") + port, _ = strconv.Atoi(portStr) + } + if port < 1 { + fmt.Fprintf(os.Stderr, "You must specify --port or PORT\n") + os.Exit(1) + } + + if nil != urlFlag && "" != *urlFlag { + host = *urlFlag + } else { + host = "http://localhost:" + strconv.Itoa(port) + } + + http.HandleFunc("/access_token", func(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s\n", r.Method, r.URL.Path) + var scheme string + if nil != r.TLS || "https" == r.Header.Get("X-Forwarded-Proto") { + scheme = "https://" + } else { + scheme = "http://" + } + _, _, token := genToken(scheme + r.Host, priv) + fmt.Fprintf(w, token) + }) + http.HandleFunc("/key.jwk.json", func(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s", r.Method, r.URL.Path) + fmt.Fprintf(w, `{ "kty": "EC" , "crv": %q , "d": %q , "x": %q , "y": %q , "ext": true , "key_ops": ["sign"] }`, jwk.Crv, jwk.D, jwk.X, jwk.Y) + }) + http.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + var scheme string + if nil != r.TLS || "https" == r.Header.Get("X-Forwarded-Proto") { + scheme = "https://" + } else { + scheme = "http://" + } + log.Printf("%s %s\n", r.Method, r.URL.Path) + fmt.Fprintf(w, `{ "issuer": "%s", "jwks_uri": "%s/.well-known/jwks.json" }`, scheme+r.Host, scheme+r.Host) + }) + http.HandleFunc("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s", r.Method, r.URL.Path) + jwkstr := fmt.Sprintf( + `{ "keys": [ { "kty": "EC" , "crv": %q , "x": %q , "y": %q , "kid": %q , "ext": true , "key_ops": ["verify"] , "exp": %s } ] }`, + jwk.Crv, jwk.X, jwk.Y, thumbprint, strconv.FormatInt(time.Now().Add(15*time.Minute).Unix(), 10), + ) + + fmt.Println(jwkstr) + fmt.Fprintf(w, jwkstr) + }) + fs := http.FileServer(http.Dir("public")) + http.Handle("/", fs) +/* + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + log.Printf(r.Method, r.URL.Path) + http.Error(w, "Not Found", http.StatusNotFound) + }) +*/ + + fmt.Printf("Serving on port %d\n", port) + go func() { + log.Fatal(http.ListenAndServe(":"+strconv.Itoa(port), nil)) + done <- true + }() + + b, _ := json.Marshal(jwk) + fmt.Printf("Private Key:\n\t%s\n", string(b)) + b, _ = json.Marshal(jwk.PublicJWK) + fmt.Printf("Public Key:\n\t%s\n", string(b)) + protected, payload, token := genToken(host, priv) + fmt.Printf("Protected (Header):\n\t%s\n", protected) + fmt.Printf("Payload (Claims):\n\t%s\n", payload) + fmt.Printf("Access Token:\n\t%s\n", token) + + <-done +} + +func genToken(host string, priv *ecdsa.PrivateKey) (string, string, string) { + thumbprint := thumbprintKey(&priv.PublicKey) + protected := fmt.Sprintf(`{"typ":"JWT","alg":"ES256","kid":"%s"}`, thumbprint) + protected64 := base64.RawURLEncoding.EncodeToString([]byte(protected)) + payload := fmt.Sprintf( + `{"iss":"%s/","sub":"dummy","exp":%s}`, + host, strconv.FormatInt(time.Now().Add(15*time.Minute).Unix(), 10), + ) + payload64 := base64.RawURLEncoding.EncodeToString([]byte(payload)) + hash := sha256.Sum256([]byte(fmt.Sprintf(`%s.%s`, protected64, payload64))) + r, s, _ := ecdsa.Sign(rand.Reader, priv, hash[:]) + rb := r.Bytes() + for len(rb) < 32 { + rb = append([]byte{0}, rb...) + } + sb := s.Bytes() + for len(rb) < 32 { + sb = append([]byte{0}, sb...) + } + sig64 := base64.RawURLEncoding.EncodeToString(append(rb, sb...)) + token := fmt.Sprintf(`%s.%s.%s`, protected64, payload64, sig64) + return protected, payload, token +} + +func parseKey(jwk *PrivateJWK) *ecdsa.PrivateKey { + xb, _ := base64.RawURLEncoding.DecodeString(jwk.X) + xi := &big.Int{} + xi.SetBytes(xb) + yb, _ := base64.RawURLEncoding.DecodeString(jwk.Y) + yi := &big.Int{} + yi.SetBytes(yb) + pub := &ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: xi, + Y: yi, + } + + db, _ := base64.RawURLEncoding.DecodeString(jwk.D) + di := &big.Int{} + di.SetBytes(db) + priv := &ecdsa.PrivateKey{ + PublicKey: *pub, + D: di, + } + return priv +} + +func thumbprintKey(pub *ecdsa.PublicKey) string { + minpub := []byte(fmt.Sprintf(`{"crv":%q,"kty":"EC","x":%q,"y":%q}`, "P-256", pub.X, pub.Y)) + sha := sha256.Sum256(minpub) + return base64.RawURLEncoding.EncodeToString(sha[:]) +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..36456ec --- /dev/null +++ b/public/index.html @@ -0,0 +1,85 @@ +
+Tokens for Testing
+Compatible with
+
+* OAuth2
+* OpenID Connect (OIDC)
+* JOSE
+ * JWT
+ * JWK (Signed Access Tokens)
+ * JWS
+
+Resources
+
+ * https://mock.pocketid.app/access_token
+ * https://xxx.mock.pocketid.app/.well-known/openid-configuration
+ * https://xxx.mock.pocketid.app/.well-known/jwks.json
+ * https://mock.pocketid.app/key.jwk.json
+
+Get a token
+
+Get a verifiable access token
+
+ https://mock.pocketid.app/access_token
+
+For example:
+
+ TOKEN=$(curl -fL https://mock.pocketid.app/access_token)
+
+The Token, Decoded
+
+The Token will look like this:
+
+ eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IlU1Ym0tQUxtSVFOQ3hfMlFPT1piSm5Ec0d1aEd4WkRnV053alNqck5IbzQifQ.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQwODAvIiwic3ViIjoiZHVtbXkiLCJleHAiOjE1NjI4NzEyNTh9.PWK2eTDQcVCpT_weogKuh4bHInnjqY6TGSnQzwEIc133WnJ1eUmLjq799COxSW7Dr6Khm1Po-CAXGVwCADIBEw
+
+The first part is the JWS protected header.
+
+Decoded, it looks like this:
+
+ {"typ":"JWT","alg":"ES256","kid":"U5bm-ALmIQNCx_2QOOZbJnDsGuhGxZDgWNwjSjrNHo4"}
+
+The second part is the JWS payload of JWT claims.
+
+Decoded, it looks like this:
+
+ {"iss":"http://localhost:4080/","sub":"dummy","exp":1562871258}
+
+The third part is the signature.
+
+It can't be "decoded", per se. It's two positive 32-bit BigInts called R and S, padded with zeros to fill out all the bytes.
+
+Validate the Issuer
+
+The token be signed and verifiable.
+
+If the token issuer (iss) is "https://mock.pocketid.app/"
+
+ * the key url (jwks_uri) will be found at "https://mock.pocketid.app/.well-known/openid-configuration"
+ * which will point to "https://mock.pocketid.app/.well-known/jwks.json"
+ * where there will be a key whose thumbprint (kid) matches that in the token
+
+The public key found there will look like this:
+
+ { "crv":"P-256"
+ , "x":"ToL2HppsTESXQKvp7ED6NMgV4YnwbMeONexNry3KDNQ"
+ , "y":"Tt6Q3rxU37KAinUV9PLMlwosNy1t3Bf2VDg5q955AGc"
+ }
+
+Get the Private Key
+
+In truth, there's only one private key right now.
+
+ https://mock.pocketid.app/key.jwk.json
+
+You shouldn't use it for automated testing, because it will change, but it looks like this:
+
+ { "crv":"P-256"
+ , "x":"ToL2HppsTESXQKvp7ED6NMgV4YnwbMeONexNry3KDNQ"
+ , "y":"Tt6Q3rxU37KAinUV9PLMlwosNy1t3Bf2VDg5q955AGc"
+ , "d":"GYAwlBHc2mPsj1lp315HbYOmKNJ7esmO3JAkZVn9nJs"
+ }
+
+