Skip to content

Commit

Permalink
Act as an OIDC provider (#54)
Browse files Browse the repository at this point in the history
* proto: minor changes to identity

* rpc/auth/email: return email as Identity.Subject

* rpc: implement getIdToken intent handling

* proto: fix incorrect Identity JSON field names

* rpc: wrap error

Co-authored-by: Vojtech Vitek (golang.cz) <[email protected]>

* rpc: improve variable naming

---------

Co-authored-by: Vojtech Vitek (golang.cz) <[email protected]>
  • Loading branch information
patrislav and VojtechVitek authored Jul 9, 2024
1 parent 882fcfb commit d5f6f3d
Show file tree
Hide file tree
Showing 10 changed files with 236 additions and 19 deletions.
8 changes: 4 additions & 4 deletions proto/authenticator.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions proto/authenticator.ridl
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ struct Identity
+ go.field.type = IdentityType
- issuer: string
+ json = iss
+ go.tag.json = iss,omitempty
- subject: string
+ json = sub
+ go.tag.json = sub,omitempty
- email: string
+ go.tag.json = email,omitempty

Expand Down
8 changes: 4 additions & 4 deletions proto/clients/authenticator.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions proto/clients/authenticator.gen.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable */
// sequence-waas-authenticator v0.1.0 78c0fb43d3928a11e1a59af0f866461164f9616c
// sequence-waas-authenticator v0.1.0 0162a6fb35dd49d6f4d924ebce4bceb847479f3a
// --
// Code generated by [email protected] with typescript generator. DO NOT EDIT.
//
Expand All @@ -12,7 +12,7 @@ export const WebRPCVersion = "v1"
export const WebRPCSchemaVersion = "v0.1.0"

// Schema hash generated from your RIDL schema
export const WebRPCSchemaHash = "78c0fb43d3928a11e1a59af0f866461164f9616c"
export const WebRPCSchemaHash = "0162a6fb35dd49d6f4d924ebce4bceb847479f3a"

//
// Types
Expand Down
6 changes: 2 additions & 4 deletions proto/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,10 @@ import (

func (id Identity) String() string {
switch id.Type {
case IdentityType_Guest:
case IdentityType_Guest, IdentityType_Email:
return string(id.Type) + ":" + id.Subject
case IdentityType_OIDC, IdentityType_PlayFab:
return string(id.Type) + ":" + id.Issuer + "#" + id.Subject
case IdentityType_Email:
return string(id.Type) + ":" + id.Email
default:
return ""
}
Expand Down Expand Up @@ -41,7 +39,7 @@ func (id *Identity) FromString(s string) error {

case IdentityType_Email:
id.Type = IdentityType_Email
id.Email = parts[1]
id.Subject = parts[1]

default:
return fmt.Errorf("invalid identity type: %s", parts[0])
Expand Down
5 changes: 3 additions & 2 deletions rpc/auth/email/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,9 @@ func (p *AuthProvider) Verify(ctx context.Context, verifCtx *proto.VerificationC
}

ident := proto.Identity{
Type: proto.IdentityType_Email,
Email: emailAddress,
Type: proto.IdentityType_Email,
Subject: emailAddress,
Email: emailAddress,
}
return ident, nil
}
Expand Down
18 changes: 15 additions & 3 deletions rpc/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ QwIDAQAB
VerificationContextsTable: "VerificationContextsTable",
},
KMS: config.KMSConfig{
SigningKey: "arn:aws:kms:us-east-1:000000000000:key/5edb0219-8da9-4842-98fb-e83c6316f3bd",
TenantKeys: []string{"arn:aws:kms:us-east-1:000000000000:key/27ebbde0-49d2-4cb6-ad78-4f2c24fe7b79"},
DefaultSessionKeys: []string{"arn:aws:kms:us-east-1:000000000000:key/27ebbde0-49d2-4cb6-ad78-4f2c24fe7b79"},
},
Expand Down Expand Up @@ -326,10 +327,11 @@ func newAccount(t *testing.T, tnt *data.Tenant, enc *enclave.Enclave, identity p
wallet, err = ethwallet.NewWalletFromRandomEntropy()
require.NoError(t, err)
}
signingSession := intents.NewSessionP256K1(wallet)

payload := &proto.AccountData{
ProjectID: tnt.ProjectID,
UserID: fmt.Sprintf("%d|%s", 1, wallet.Address()),
UserID: fmt.Sprintf("%d|%s", tnt.ProjectID, signingSession.SessionID()),
Identity: identity.String(),
CreatedAt: time.Now(),
}
Expand Down Expand Up @@ -360,8 +362,9 @@ func newOIDCIdentity(issuer string) proto.Identity {

func newEmailIdentity(email string) proto.Identity {
return proto.Identity{
Type: proto.IdentityType_Email,
Email: email,
Type: proto.IdentityType_Email,
Subject: email,
Email: email,
}
}

Expand Down Expand Up @@ -414,6 +417,15 @@ func newSession(t *testing.T, tnt *data.Tenant, enc *enclave.Enclave, issuer str
return newSessionFromData(t, tnt, enc, payload)
}

func unmarshalResponse[T any](t *testing.T, data any) *T {
b, err := json.Marshal(data)
require.NoError(t, err)

var res T
require.NoError(t, json.Unmarshal(b, &res))
return &res
}

type walletServiceMock struct {
registeredUsers map[string]struct{}
registeredSessions map[string]struct{}
Expand Down
101 changes: 101 additions & 0 deletions rpc/identity_provider.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
package rpc

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"time"

"github.com/0xsequence/go-sequence/intents"
"github.com/0xsequence/waas-authenticator/data"
"github.com/0xsequence/waas-authenticator/proto"
"github.com/0xsequence/waas-authenticator/rpc/crypto"
"github.com/0xsequence/waas-authenticator/rpc/signing"
"github.com/0xsequence/waas-authenticator/rpc/tenant"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/lestrrat-go/jwx/v2/jwt"
)

type openidConfig struct {
Expand Down Expand Up @@ -54,3 +67,91 @@ func (s *RPC) handleJWKS(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(pkd)
}

func (s *RPC) getIDToken(
ctx context.Context, sess *data.Session, intent *intents.IntentTyped[intents.IntentDataGetIdToken],
) (*intents.IntentResponseIdToken, error) {
tnt := tenant.FromContext(ctx)

if len(intent.Data.Nonce) > 128 {
return nil, fmt.Errorf("invalid nonce")
}

sessData, _, err := crypto.DecryptData[*proto.SessionData](ctx, sess.EncryptedKey, sess.Ciphertext, tnt.KMSKeys)
if err != nil {
return nil, err
}

var identity proto.Identity
if err := identity.FromString(sessData.Identity); err != nil {
return nil, fmt.Errorf("parsing session identity: %w", err)
}

account, found, err := s.Accounts.Get(ctx, tnt.ProjectID, identity)
if err != nil {
return nil, fmt.Errorf("getting account: %w", err)
}
if !found {
return nil, fmt.Errorf("account not found")
}

walletAddr, err := AddressForUser(ctx, tnt, account.UserID)
if err != nil {
return nil, fmt.Errorf("getting wallet address: %w", err)
}

aud := fmt.Sprintf("%s/project/%d", s.Config.Builder.BaseURL, tnt.ProjectID)
iat := time.Now()
exp := iat.Add(10 * time.Minute)

tokenBuilder := jwt.NewBuilder().
Subject(walletAddr).
Audience([]string{aud}).
Issuer(s.Config.BaseURL).
IssuedAt(iat).
Expiration(exp).
Claim("auth_time", sessData.CreatedAt.Unix()).
Claim(s.Config.BaseURL+"/identity", identity)

if account.Email != "" {
tokenBuilder.Claim("email", account.Email)
}

if intent.Data.Nonce != "" {
tokenBuilder.Claim("nonce", intent.Data.Nonce)
}

token, err := tokenBuilder.Build()
if err != nil {
return nil, err
}

serialized, err := jwt.NewSerializer().Serialize(token)
if err != nil {
return nil, err
}

// these can't fail, thus we ignore the errors
headers := jws.NewHeaders()
_ = headers.Set(jws.AlgorithmKey, jwa.RS256)
_ = headers.Set(jws.KeyIDKey, s.Signer.KeyID())
_ = headers.Set(jws.TypeKey, "JWT")

jsonHeaders, err := json.Marshal(headers)
if err != nil {
return nil, err
}

payload := base64.RawURLEncoding.EncodeToString(jsonHeaders) + "." + base64.RawURLEncoding.EncodeToString(serialized)
signature, err := s.Signer.Sign(ctx, signing.AlgorithmRsaPkcs1V15Sha256, []byte(payload))
if err != nil {
return nil, err
}

signed := payload + "." + base64.RawURLEncoding.EncodeToString(signature)
res := &intents.IntentResponseIdToken{
IdToken: signed,
ExpiresIn: int(exp.Sub(iat).Seconds()),
}
return res, nil
}
92 changes: 92 additions & 0 deletions rpc/identity_provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package rpc_test

import (
"context"
"net/http"
"net/http/httptest"
"strconv"
"testing"

"github.com/0xsequence/ethkit/ethwallet"
"github.com/0xsequence/go-sequence/intents"
"github.com/0xsequence/waas-authenticator/proto"
"github.com/0xsequence/waas-authenticator/rpc"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestRPC_SendIntent_GetIdToken(t *testing.T) {
ctx := context.Background()

issuer, _, closeJWKS := issueAccessTokenAndRunJwksServer(t, func(b *jwt.Builder, s string) {
b.Claim("email", "[email protected]").Claim("email_verified", true)
})
defer closeJWKS()

sessWallet, err := ethwallet.NewWalletFromRandomEntropy()
require.NoError(t, err)
signingSession := intents.NewSessionP256K1(sessWallet)

svc := initRPC(t)

tenant, tntData := newTenant(t, svc.Enclave, issuer)
acc := newAccount(t, tenant, svc.Enclave, newOIDCIdentity(issuer), sessWallet)
sess := newSession(t, tenant, svc.Enclave, issuer, signingSession)

walletAddr, err := rpc.AddressForUser(context.Background(), tntData, acc.UserID)
require.NoError(t, err)

require.NoError(t, svc.Tenants.Add(ctx, tenant))
require.NoError(t, svc.Accounts.Put(ctx, acc))
require.NoError(t, svc.Sessions.Put(ctx, sess))

srv := httptest.NewServer(svc.Handler())
defer srv.Close()
svc.Config.BaseURL = srv.URL
svc.Config.Builder.BaseURL = "https://sequence.build"

intentData := &intents.IntentDataGetIdToken{
Wallet: walletAddr,
SessionID: sess.ID,
Nonce: "NONCE",
}
intent := generateSignedIntent(t, intents.IntentName_getIdToken, intentData, signingSession)

c := proto.NewWaasAuthenticatorClient(srv.URL, http.DefaultClient)
header := make(http.Header)
header.Set("X-Sequence-Project", strconv.Itoa(int(tenant.ProjectID)))
ctx, err = proto.WithHTTPRequestHeaders(ctx, header)
require.NoError(t, err)

res, err := c.SendIntent(ctx, intent)
require.NoError(t, err)
assert.Equal(t, proto.IntentResponseCode_idToken, res.Code)
assert.NotEmpty(t, res.Data)

resData := unmarshalResponse[intents.IntentResponseIdToken](t, res.Data)

jwks, err := jwk.Fetch(context.Background(), srv.URL+"/.well-known/jwks.json")
require.NoError(t, err)

opts := []jwt.ParseOption{
jwt.WithKeySet(jwks),
jwt.WithIssuer(srv.URL),
jwt.WithAudience("https://sequence.build/project/" + strconv.Itoa(int(tenant.ProjectID))),
jwt.WithSubject(walletAddr),
jwt.WithClaimValue("nonce", "NONCE"),
jwt.WithClaimValue("email", "[email protected]"),
}
tok, err := jwt.Parse([]byte(resData.IdToken), opts...)
require.NoError(t, err)
require.NotNil(t, tok)

identClaim, ok := tok.Get(srv.URL + "/identity")
require.True(t, ok)
identMap, ok := identClaim.(map[string]any)
require.True(t, ok, "should be a map, is %+v", identClaim)
assert.Equal(t, "OIDC", identMap["type"])
assert.Equal(t, issuer, identMap["iss"])
assert.Equal(t, "SUBJECT", identMap["sub"])
}
11 changes: 11 additions & 0 deletions rpc/intents.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,17 @@ func (s *RPC) SendIntent(ctx context.Context, protoIntent *proto.Intent) (*proto
return nil, err
}
return makeIntentResponse(proto.IntentResponseCode_accountRemoved, true), nil

case intents.IntentName_getIdToken:
intentTyped, err := intents.NewIntentTypedFromIntent[intents.IntentDataGetIdToken](intent)
if err != nil {
return nil, err
}
res, err := s.getIDToken(ctx, sess, intentTyped)
if err != nil {
return nil, err
}
return makeIntentResponse(proto.IntentResponseCode_idToken, res), nil
}

// Generic forwarding of intent, no special handling
Expand Down

0 comments on commit d5f6f3d

Please sign in to comment.