Skip to content

Commit

Permalink
chore: add implementation examples (#3)
Browse files Browse the repository at this point in the history
Add example of entire DPoP authorization flow,
from creating a bound token to accepting that bound token.

The example can be run with a simple bash script
that starts local servers and runs a
client that attempts to get a resource.
  • Loading branch information
SalladinBalwer authored Sep 22, 2023
1 parent 43d01ee commit 570b226
Show file tree
Hide file tree
Showing 9 changed files with 492 additions and 7 deletions.
2 changes: 1 addition & 1 deletion .github/actions/vet-test/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ runs:

- name: Test
shell: bash
run: go test -v -coverprofile=profile.cov ./...
run: go test -v -coverprofile=profile.cov ./.
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
branches:
- main
paths:
- "**.go"
- "*.go"

jobs:
vet-and-test:
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,7 @@
go.work

# Visual Studio Code workspace settings
.vscode/
.vscode/

# Example binaries
build/
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ proof, err := dpop.Parse(proofString, dpop.POST, &httpUrl, dpop.ParseOptions{
})
// Check the error type to determine response
if err != nil {
if ok := errors.Is(dpop.ErrInvalidProof); ok {
if ok := errors.Is(err, dpop.ErrInvalidProof); ok {
// Return 'invalid_dpop_proof'
}
}
Expand All @@ -56,7 +56,7 @@ proof, err := dpop.Parse(proofString, dpop.POST, &httpUrl, dpop.ParseOptions{
})
// Check the error type to determine response
if err != nil {
if ok := errors.Is(dpop.ErrInvalidProof); ok {
if ok := errors.Is(err, dpop.ErrInvalidProof); ok {
// Return 'invalid_dpop_proof'
}
}
Expand All @@ -68,10 +68,10 @@ if err != nil {
err = proof.Validate(accessTokenHash, accessTokenJWT)
// Check the error type to determine response
if err != nil {
if ok := errors.Is(dpop.ErrInvalidProof); ok {
if ok := errors.Is(err, dpop.ErrInvalidProof); ok {
// Return 'invalid_dpop_proof'
}
if ok := errors.Is(dpop.ErrIncorrectAccessTokenClaimsType); ok {
if ok := errors.Is(err, dpop.ErrIncorrectAccessTokenClaimsType); ok {
// Return 'invalid_token'
}
}
Expand Down
17 changes: 17 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Example Implementation

Working implementation of authorization server, resource server and a client that communicates with them.

Run:

```sh
./run-example.sh
```

This will start 2 servers running on localhost, an authorization server and a resource server.
When the enter key is pressed (this allows the servers to start correctly) the client will be started and attempt to get the resource from the resource server.

The client will create a private `ES256` key and create a proof that will be used to request a bound token from the authorization server.
The returned bound token will then be sent together with a new bound proof to the resource server, the resource server will then validate the signature of the bound token, the signature of the proof and the binding between them.

If everything is validated successfully the resource server will respond with the resource to the client.
162 changes: 162 additions & 0 deletions examples/authorization_server/auth_server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package main

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"

"github.com/AxisCommunications/go-dpop"
"github.com/golang-jwt/jwt/v5"
)

var privateKey *ecdsa.PrivateKey

type BoundTokenClaims struct {
*dpop.BoundAccessTokenClaims
Scope []string `json:"scope"`
}

type ECJWK struct {
KTY string `json:"kty"`
CRV string `json:"crv"`
KID string `json:"kid"`
X string `json:"x"`
Y string `json:"y"`
}

// postToken will accept requests to create a bound a token
func postToken(w http.ResponseWriter, r *http.Request) {
fmt.Printf("Authorization server - got /token request\n")
httpUrl, _ := url.Parse("https://server.example.com/token")

// read proof header
proofString := r.Header.Get("dpop")
if proofString == "" {
fmt.Println("No proof")
w.WriteHeader(http.StatusBadRequest)
return
}

// validate proof
acceptedTimeWindow := time.Hour * 24 * 365 * 10
proof, err := dpop.Parse(proofString, dpop.POST, httpUrl, dpop.ParseOptions{
TimeWindow: &acceptedTimeWindow,
})
// Check the error type to determine response
if err != nil {
if ok := errors.Is(err, dpop.ErrInvalidProof); ok {
w.WriteHeader(http.StatusBadRequest)
io.WriteString(w, err.Error())
return
}
}

// proof is valid, get public key to associate with access token
jkt := proof.PublicKey()

// The server should check credentials of calling user here to ensure that the user has access to this api
// but is skipped here for simplicity.

// read body
defer r.Body.Close()
b, err := io.ReadAll(r.Body)
if err != nil {
fmt.Println(err)
w.WriteHeader(http.StatusBadRequest)
io.WriteString(w, "unreadable body")
return
}

// parse body
data := &struct {
Resource string `json:"resource"`
Scope []string `json:"scope"`
}{}
err = json.Unmarshal(b, &data)
if err != nil {
fmt.Println(err)
w.WriteHeader(http.StatusBadRequest)
io.WriteString(w, "malformed body")
return
}

// It is a good idea to validate that the caller has the access that they are requesting
// but is skipped here for simplicity.

// create bound JWT
claims := &BoundTokenClaims{
BoundAccessTokenClaims: &dpop.BoundAccessTokenClaims{
RegisteredClaims: &jwt.RegisteredClaims{
Issuer: "example.com",
Subject: "user",
Audience: []string{data.Resource},
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
ID: "unique-id",
},
Confirmation: dpop.Confirmation{JWKThumbprint: jkt},
},
Scope: data.Scope,
}
boundToken := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
boundToken.Header["kid"] = "auth-key"
boundTokenString, err := boundToken.SignedString(privateKey)
if err != nil {
fmt.Println(err)
w.WriteHeader(http.StatusInternalServerError)
io.WriteString(w, "could not create token")
return
}

// return signed bound JWT
io.WriteString(w, boundTokenString)
}

// getKeys returns a list of public keys that can be used to verify bound tokens.
func getKeys(w http.ResponseWriter, r *http.Request) {
fmt.Printf("Authorization server - got /keys request\n")
jwks := &struct {
Keys []ECJWK `json:"keys"`
}{[]ECJWK{{
KTY: "EC",
CRV: privateKey.PublicKey.Params().Name,
KID: "auth-key",
X: base64.RawURLEncoding.Strict().EncodeToString(privateKey.PublicKey.X.Bytes()),
Y: base64.RawURLEncoding.Strict().EncodeToString(privateKey.PublicKey.Y.Bytes()),
}}}

response, err := json.Marshal(jwks)
if err != nil {
fmt.Println(err)
w.WriteHeader(http.StatusInternalServerError)
io.WriteString(w, err.Error())
return
}

io.WriteString(w, string(response))
}

func main() {
// generate private key that will be used to sign authorization server tokens
var err error
privateKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
fmt.Println(err)
return
}

// start server
http.HandleFunc("/token", postToken)
http.HandleFunc("/keys", getKeys)

err = http.ListenAndServe(":1337", nil)
fmt.Println(err)
}
124 changes: 124 additions & 0 deletions examples/client/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package main

import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"time"

"github.com/AxisCommunications/go-dpop"
"github.com/golang-jwt/jwt/v5"
)

type tokenRequestBody struct {
Resource string `json:"resource"`
Scope []string `json:"scope"`
}

func main() {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}

// Create a DPoP proof token in order to request a bound token from the autorization server
claims := dpop.ProofTokenClaims{
RegisteredClaims: &jwt.RegisteredClaims{
Issuer: "client",
Subject: "user",
Audience: jwt.ClaimStrings{"https://server.example.com/token"},
ID: "random_id",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
Method: dpop.POST,
URL: "https://server.example.com/token",
}
proof, err := dpop.Create(jwt.SigningMethodES256, &claims, privateKey)
if err != nil {
panic(err)
}

// Request a bound token from the autorization server
fmt.Println("Client - requesting a bound token from the authorization server")
body := tokenRequestBody{
Resource: "https://server.example.com/resource",
Scope: []string{"read", "write"},
}
jsonBody, err := json.Marshal(body)
if err != nil {
panic(err)
}
req, err := http.NewRequest("POST", "http://localhost:1337/token", bytes.NewReader(jsonBody))
if err != nil {
panic(err)
}
req.Header.Add("dpop", proof)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}

// Read the bound token from the response
boundTokenString, err := io.ReadAll(res.Body)
if err != nil {
panic(err)
}
res.Body.Close()
fmt.Printf("Client - received bound token: %s\n", boundTokenString)

// Create a bound DPoP proof token in order to access the resource server
h := sha256.New()
_, err = h.Write([]byte(boundTokenString))
if err != nil {
panic(err)
}
ath := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
claims = dpop.ProofTokenClaims{
RegisteredClaims: &jwt.RegisteredClaims{
Issuer: "client",
Subject: "user",
Audience: jwt.ClaimStrings{"https://server.example.com/resource"},
ID: "random_id",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
Method: dpop.GET,
URL: "https://server.example.com/resource",
// This binds the proof to the bound token
AccessTokenHash: ath,
}
// Sign with the same private key used to sign the proof that was sent to the authorization server
boundProof, err := dpop.Create(jwt.SigningMethodES256, &claims, privateKey)
if err != nil {
panic(err)
}

// Access the resource server
fmt.Println("Client - accessing the resource server")
req, err = http.NewRequest("POST", "http://localhost:40000/resource", bytes.NewReader(jsonBody))
if err != nil {
panic(err)
}
req.Header.Add("dpop", boundProof)
req.Header.Add("Authorization", fmt.Sprintf("dpop %s", boundTokenString))
resourceRes, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}

// Read the resource server response
defer res.Body.Close()
resourceBody, err := io.ReadAll(resourceRes.Body)
if err != nil {
panic(err)
}
fmt.Printf("Client - resource server response: %s\n", string(resourceBody))
}
Loading

0 comments on commit 570b226

Please sign in to comment.