Skip to content

Commit

Permalink
- make sure that we can generate offline tokens for Epic providers (u…
Browse files Browse the repository at this point in the history
…sing dynamic client registration - https://fhir.epic.com/Documentation?docId=Oauth2&section=Standalone-Oauth2-OfflineAccess-0)

- when SourceCredential is sent to the CreateSource API, we'll check if dynamic client regirstion is enabled. If it is, we'll use this token to register a new client, store the client information, and geneate a new Access Token using the key pair associated with this newly registered client.
- added additional fields to SourceCredential table (registration_endpoint, dynamic_client_registration_mode, dynamic_client_jwks, dynamic_client_id)
- renamed RefreshTokens to SetTokens in SourceCredential model - to have consistent naming
- added IsDynamicClient and RefreshDynamicClientAccessToken methods to SourceCredential Model

fixes fastenhealth#178
  • Loading branch information
AnalogJ committed Jul 20, 2023
1 parent be30cb0 commit a5b3715
Show file tree
Hide file tree
Showing 12 changed files with 445 additions and 12 deletions.
70 changes: 70 additions & 0 deletions backend/pkg/jwk/jwk_utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package jwk

import (
"crypto/rand"
"crypto/rsa"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/lestrrat-go/jwx/v2/jwk"
)

//see https://github.com/lestrrat-go/jwx/blob/v2/docs/04-jwk.md#working-with-key-specific-methods
func JWKGenerate() (jwk.RSAPrivateKey, error) {
raw, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("failed to generate RSA private key: %s\n", err)
}

key, err := jwk.FromRaw(raw)
if err != nil {
return nil, fmt.Errorf("failed to create jwk.Key from RSA private key: %s\n", err)
}

rsakey, ok := key.(jwk.RSAPrivateKey)
if !ok {
return nil, fmt.Errorf("failed to convert jwk.Key into jwk.RSAPrivateKey (was %T)\n", key)
}
rsakey.Set("kid", uuid.New().String())

//_ = rsakey.D()
//_ = rsakey.DP()
//_ = rsakey.DQ()
//_ = rsakey.E()
//_ = rsakey.N()
//_ = rsakey.P()
//_ = rsakey.Q()
//_ = rsakey.QI()
//// OUTPUT:

return rsakey, nil
}

func JWKSerialize(privateKey jwk.RSAPrivateKey) (map[string]string, error) {
jsonbuf, err := json.Marshal(privateKey)
if err != nil {
return nil, err
}
var dict map[string]string
err = json.Unmarshal(jsonbuf, &dict)
if err != nil {
return nil, err
}
if privateKey.KeyID() != "" {
dict["kid"] = privateKey.KeyID()
}

return dict, err
}

func JWKDeserialize(privateKeyDict map[string]string) (jwk.RSAPrivateKey, error) {
jsonbuf, err := json.Marshal(privateKeyDict)
if err != nil {
return nil, err
}
key, err := jwk.ParseKey(jsonbuf)
if err != nil {
return nil, fmt.Errorf("failed to create jwk.Key from RSA private key: %s\n", err)
}
return key.(jwk.RSAPrivateKey), nil
}
60 changes: 60 additions & 0 deletions backend/pkg/jwk/jwk_utils_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package jwk

import (
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/stretchr/testify/require"
"testing"
)

func TestJWKSerialize(t *testing.T) {
//test
keypair, err := JWKGenerate()
require.NoError(t, err)

dict, err := JWKSerialize(keypair)
require.NoError(t, err)

keys := []string{}
for key, _ := range dict {
keys = append(keys, key)
}

//assert
require.NotEmpty(t, keypair.KeyID())
require.ElementsMatch(t, []string{"d", "dp", "dq", "e", "kty", "n", "p", "q", "qi", "kid"}, keys)
require.Equal(t, "RSA", dict["kty"])
//require.Equal(t, map[string]string{}, dict)

require.Equal(t, string(keypair.N()), dict["n"])
}

func TestJWKDeserialize(t *testing.T) {
//setup
keypairDict := map[string]string{
"kty": "RSA",
"kid": "cc34c0a0-bd5a-4a3c-a50d-a2a7db7643df",
"n": "pjdss8ZaDfEH6K6U7GeW2nxDqR4IP049fk1fK0lndimbMMVBdPv_hSpm8T8EtBDxrUdi1OHZfMhUixGaut-3nQ4GG9nM249oxhCtxqqNvEXrmQRGqczyLxuh-fKn9Fg--hS9UpazHpfVAFnB5aCfXoNhPuI8oByyFKMKaOVgHNqP5NBEqabiLftZD3W_lsFCPGuzr4Vp0YS7zS2hDYScC2oOMu4rGU1LcMZf39p3153Cq7bS2Xh6Y-vw5pwzFYZdjQxDn8x8BG3fJ6j8TGLXQsbKH1218_HcUJRvMwdpbUQG5nvA2GXVqLqdwp054Lzk9_B_f1lVrmOKuHjTNHq48w",
"e": "AQAB",
"d": "ksDmucdMJXkFGZxiomNHnroOZxe8AmDLDGO1vhs-POa5PZM7mtUPonxwjVmthmpbZzla-kg55OFfO7YcXhg-Hm2OWTKwm73_rLh3JavaHjvBqsVKuorX3V3RYkSro6HyYIzFJ1Ek7sLxbjDRcDOj4ievSX0oN9l-JZhaDYlPlci5uJsoqro_YrE0PRRWVhtGynd-_aWgQv1YzkfZuMD-hJtDi1Im2humOWxA4eZrFs9eG-whXcOvaSwO4sSGbS99ecQZHM2TcdXeAs1PvjVgQ_dKnZlGN3lTWoWfQP55Z7Tgt8Nf1q4ZAKd-NlMe-7iqCFfsnFwXjSiaOa2CRGZn-Q",
"p": "4A5nU4ahEww7B65yuzmGeCUUi8ikWzv1C81pSyUKvKzu8CX41hp9J6oRaLGesKImYiuVQK47FhZ--wwfpRwHvSxtNU9qXb8ewo-BvadyO1eVrIk4tNV543QlSe7pQAoJGkxCia5rfznAE3InKF4JvIlchyqs0RQ8wx7lULqwnn0",
"q": "ven83GM6SfrmO-TBHbjTk6JhP_3CMsIvmSdo4KrbQNvp4vHO3w1_0zJ3URkmkYGhz2tgPlfd7v1l2I6QkIh4Bumdj6FyFZEBpxjE4MpfdNVcNINvVj87cLyTRmIcaGxmfylY7QErP8GFA-k4UoH_eQmGKGK44TRzYj5hZYGWIC8",
"dp": "lmmU_AG5SGxBhJqb8wxfNXDPJjf__i92BgJT2Vp4pskBbr5PGoyV0HbfUQVMnw977RONEurkR6O6gxZUeCclGt4kQlGZ-m0_XSWx13v9t9DIbheAtgVJ2mQyVDvK4m7aRYlEceFh0PsX8vYDS5o1txgPwb3oXkPTtrmbAGMUBpE",
"dq": "mxRTU3QDyR2EnCv0Nl0TCF90oliJGAHR9HJmBe__EjuCBbwHfcT8OG3hWOv8vpzokQPRl5cQt3NckzX3fs6xlJN4Ai2Hh2zduKFVQ2p-AF2p6Yfahscjtq-GY9cB85NxLy2IXCC0PF--Sq9LOrTE9QV988SJy_yUrAjcZ5MmECk",
"qi": "ldHXIrEmMZVaNwGzDF9WG8sHj2mOZmQpw9yrjLK9hAsmsNr5LTyqWAqJIYZSwPTYWhY4nu2O0EY9G9uYiqewXfCKw_UngrJt8Xwfq1Zruz0YY869zPN4GiE9-9rzdZB33RBw8kIOquY3MK74FMwCihYx_LiU2YTHkaoJ3ncvtvg",
}

//test
keypair, err := JWKDeserialize(keypairDict)
require.NoError(t, err)

serialized, err := JWKSerialize(keypair)
require.NoError(t, err)

//assert
require.NotEmpty(t, keypair.KeyID())
require.Equal(t, keypair.KeyID(), "cc34c0a0-bd5a-4a3c-a50d-a2a7db7643df")
require.Equal(t, keypair.KeyType(), jwa.KeyType("RSA"))

require.Equal(t, keypairDict, serialized)

}
17 changes: 17 additions & 0 deletions backend/pkg/models/client_registration_request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package models

type ClientRegistrationRequest struct {
SoftwareId string `json:"software_id"`
Jwks ClientRegistrationRequestJwks `json:"jwks"`
}

type ClientRegistrationRequestJwks struct {
Keys []ClientRegistrationRequestJwksKey `json:"keys"`
}

type ClientRegistrationRequestJwksKey struct {
KeyId string `json:"kid"`
KeyType string `json:"kty"`
PublicExponent string `json:"e"`
Modulus string `json:"n"`
}
31 changes: 31 additions & 0 deletions backend/pkg/models/client_registration_response.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package models

// ClientRegistrationResponse {
// "redirect_uris": [
// " https://fhir.epic.com/test/smart"
// ],
// "token_endpoint_auth_method": "none",
// "grant_types": [
// "urn:ietf:params:oauth:grant-type:jwt-bearer"
// ],
// "software_id": " d45049c3-3441-40ef-ab4d-b9cd86a17225",
// "client_id": "G65DA2AF4-1C91-11EC-9280-0050568B7514",
// "client_id_issued_at": 1632417134,
// "jwks": {
// "keys": [{
// "kty": "RSA",
// "n": "vGASMnWdI-ManPgJi5XeT15Uf1tgpaNBmxfa-_bKG6G1DDTsYBy2K1uubppWMcl8Ff_2oWe6wKDMx2-bvrQQkR1zcV96yOgNmfDXuSSR1y7xk1Kd-uUhvmIKk81UvKbKOnPetnO1IftpEBm5Llzy-1dN3kkJqFabFSd3ujqi2ZGuvxfouZ-S3lpTU3O6zxNR6oZEbP2BwECoBORL5cOWOu_pYJvALf0njmamRQ2FKKCC-pf0LBtACU9tbPgHorD3iDdis1_cvk16i9a3HE2h4Hei4-nDQRXfVgXLzgr7GdJf1ArR1y65LVWvtuwNf7BaxVkEae1qKVLa2RUeg8imuw",
// "e": "AQAB"
// }
// ]
// }
//}
type ClientRegistrationResponse struct {
RedirectUrls []string `json:"redirect_uris"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
GrantTypes []string `json:"grant_types"`
SoftwareId string `json:"software_id"`
ClientId string `json:"client_id"`
ClientIdIssuedAt int `json:"client_id_issued_at"`
Jwks ClientRegistrationRequestJwks `json:"jwks"`
}
11 changes: 11 additions & 0 deletions backend/pkg/models/client_registration_token_response.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package models

type ClientRegistrationTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
State string `json:"state"`
Patient string `json:"patient"`
EpicDstu2Patient string `json:"__epic.dstu2.patient"`
}
102 changes: 98 additions & 4 deletions backend/pkg/models/source_credential.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
package models

import (
"encoding/json"
"fmt"
"github.com/fastenhealth/fasten-sources/pkg"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/jwk"
"github.com/google/uuid"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwt"
"io"
"log"
"net/http"
"net/url"
"time"
)

// SourceCredential Data/Medical Provider Credentials
Expand All @@ -18,6 +28,7 @@ type SourceCredential struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
IntrospectionEndpoint string `json:"introspection_endpoint"`
RegistrationEndpoint string `json:"registration_endpoint"` //optional - required when Dynamic Client Registration mode is set

Scopes []string `json:"scopes_supported" gorm:"type:text;serializer:json"`
Issuer string `json:"issuer"`
Expand All @@ -33,8 +44,9 @@ type SourceCredential struct {
ClientId string `json:"client_id"`
RedirectUri string `json:"redirect_uri"` //lighthouse url the provider will redirect to (registered with App)

Confidential bool `json:"confidential"` //if enabled, requires client_secret to authenticate with provider (PKCE)
CORSRelayRequired bool `json:"cors_relay_required"` //if true, requires CORS proxy/relay, as provider does not return proper response to CORS preflight
Confidential bool `json:"confidential"` //if enabled, requires client_secret to authenticate with provider (PKCE)
DynamicClientRegistrationMode string `json:"dynamic_client_registration_mode"` //if enabled, will dynamically register client with provider (https://oauth.net/2/dynamic-client-registration/)
CORSRelayRequired bool `json:"cors_relay_required"` //if true, requires CORS proxy/relay, as provider does not return proper response to CORS preflight
//SecretKeyPrefix string `json:"-"` //the secret key prefix to use, if empty (default) will use the sourceType value

// auth/credential data
Expand All @@ -44,6 +56,10 @@ type SourceCredential struct {
ExpiresAt int64 `json:"expires_at"`
CodeChallenge string `json:"code_challenge"`
CodeVerifier string `json:"code_verifier"`

//dynamic client auth/credential data
DynamicClientJWKS []map[string]string `json:"dynamic_client_jwks" gorm:"type:text;serializer:json"`
DynamicClientId string `json:"dynamic_client_id"`
}

func (s *SourceCredential) GetSourceType() pkg.SourceType {
Expand Down Expand Up @@ -82,7 +98,7 @@ func (s *SourceCredential) GetExpiresAt() int64 {
return s.ExpiresAt
}

func (s *SourceCredential) RefreshTokens(accessToken string, refreshToken string, expiresAt int64) {
func (s *SourceCredential) SetTokens(accessToken string, refreshToken string, expiresAt int64) {
if expiresAt > 0 && expiresAt != s.ExpiresAt {
s.ExpiresAt = expiresAt
}
Expand All @@ -108,5 +124,83 @@ tokenResponse: payload,
expiresAt: getAccessTokenExpiration(payload, new BrowserAdapter()),
codeChallenge: codeChallenge,
codeVerifier: codeVerifier
*/

// IsDynamicClient this method is used to check if this source uses dynamic client registration (used to customize token refresh logic)
func (s *SourceCredential) IsDynamicClient() bool {
return len(s.DynamicClientRegistrationMode) > 0
}

//this will set/update the AccessToken and Expiry using the dynamic client credentials
func (s *SourceCredential) RefreshDynamicClientAccessToken() error {
if len(s.DynamicClientRegistrationMode) == 0 {
return fmt.Errorf("dynamic client registration mode not set")
}
if len(s.DynamicClientJWKS) == 0 {
return fmt.Errorf("dynamic client jwks not set")
}
if len(s.DynamicClientId) == 0 {
return fmt.Errorf("dynamic client id not set")
}

//convert the serialized dynamic-client credentials to a jwx.Key
jwkeypair, err := jwk.JWKDeserialize(s.DynamicClientJWKS[0])
if err != nil {
return err
}

// see https://github.com/lestrrat-go/jwx/tree/v2/jwt#token-usage
t := jwt.New()
t.Set("kid", jwkeypair.KeyID())
t.Set(jwt.SubjectKey, s.DynamicClientId)
t.Set(jwt.AudienceKey, s.TokenEndpoint)
t.Set(jwt.JwtIDKey, uuid.New().String())
t.Set(jwt.ExpirationKey, time.Now().Add(time.Minute*2).Unix()) // must be less than 5 minutes from now. Time when this JWT expires
t.Set(jwt.IssuedAtKey, time.Now().Unix())
t.Set(jwt.IssuerKey, s.DynamicClientId)

//sign the jwt with the private key
// Signing a token (using raw rsa.PrivateKey)
signed, err := jwt.Sign(t, jwt.WithKey(jwa.RS256, jwkeypair))
if err != nil {
return fmt.Errorf("failed to sign dynamic-client jwt: %s", err)
}

//send this signed jwt to the token endpoint to get a new access token
// https://fhir.epic.com/Documentation?docId=oauth2&section=JWKS

postForm := url.Values{
"grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"},
"assertion": {string(signed)},
"client_id": {s.DynamicClientId},
}

tokenResp, err := http.PostForm(s.TokenEndpoint, postForm)

if err != nil {
return fmt.Errorf("an error occurred while sending dynamic client token request, %s", err)
}

defer tokenResp.Body.Close()
if tokenResp.StatusCode >= 300 || tokenResp.StatusCode < 200 {

b, err := io.ReadAll(tokenResp.Body)
if err != nil {
log.Printf("Response body: %s", string(b))
}

return fmt.Errorf("an error occurred while reading dynamic client token response, status code was not 200: %d", tokenResp.StatusCode)
}

var registrationTokenResponseBytes ClientRegistrationTokenResponse
err = json.NewDecoder(tokenResp.Body).Decode(&registrationTokenResponseBytes)
if err != nil {
return fmt.Errorf("an error occurred while parsing dynamic client token response", err)
}

//update the source credential with the new access token
s.AccessToken = registrationTokenResponseBytes.AccessToken
s.ExpiresAt = time.Now().Add(time.Second * time.Duration(registrationTokenResponseBytes.ExpiresIn)).Unix()

return nil
}
Loading

0 comments on commit a5b3715

Please sign in to comment.