Skip to content

Commit

Permalink
Commit/reveal OIDC flow (#47)
Browse files Browse the repository at this point in the history
* rpc/auth: implement guest.AuthProvider

* rpc/auth: implement oidc.AuthProvider

* rpc: make the Guest and OIDC providers available

* rpc: add basic tests for guest and oidc auth
  • Loading branch information
patrislav authored Jun 25, 2024
1 parent 489ca52 commit b117700
Show file tree
Hide file tree
Showing 7 changed files with 525 additions and 3 deletions.
6 changes: 6 additions & 0 deletions proto/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (

func (id Identity) String() string {
switch id.Type {
case IdentityType_Guest:
return string(id.Type) + ":" + id.Subject
case IdentityType_OIDC:
return string(id.Type) + ":" + id.Issuer + "#" + id.Subject
case IdentityType_Email:
Expand All @@ -23,6 +25,10 @@ func (id *Identity) FromString(s string) error {
}

switch IdentityType(parts[0]) {
case IdentityType_Guest:
id.Type = IdentityType_Guest
id.Subject = parts[1]

case IdentityType_OIDC:
oidcParts := strings.SplitN(parts[1], "#", 2)
if len(oidcParts) != 2 {
Expand Down
4 changes: 4 additions & 0 deletions rpc/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ func (s *RPC) federateAccount(
return nil, fmt.Errorf("get auth provider: %w", err)
}

if intent.Data.IdentityType == intents.IdentityType_Guest {
return nil, fmt.Errorf("cannot federate a guest account")
}

var verifCtx *proto.VerificationContext
authID := data.AuthID{
ProjectID: tntData.ProjectID,
Expand Down
119 changes: 119 additions & 0 deletions rpc/auth/guest/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package guest

import (
"context"
"fmt"
"io"
"time"

"github.com/0xsequence/ethkit/go-ethereum/common/hexutil"
ethcrypto "github.com/0xsequence/ethkit/go-ethereum/crypto"
"github.com/0xsequence/go-sequence/intents"
"github.com/0xsequence/waas-authenticator/proto"
"github.com/0xsequence/waas-authenticator/rpc/attestation"
"github.com/0xsequence/waas-authenticator/rpc/auth"
"github.com/0xsequence/waas-authenticator/rpc/tenant"
)

type AuthProvider struct{}

func NewAuthProvider() auth.Provider {
return &AuthProvider{}
}

func (AuthProvider) IsEnabled(tenant *proto.TenantData) bool {
return tenant.AuthConfig.Guest.Enabled == true
}

func (p AuthProvider) InitiateAuth(
ctx context.Context,
verifCtx *proto.VerificationContext,
verifier string,
sessionID string,
storeFn auth.StoreVerificationContextFn,
) (*intents.IntentResponseAuthInitiated, error) {
att := attestation.FromContext(ctx)
tnt := tenant.FromContext(ctx)

if verifier != sessionID {
return nil, fmt.Errorf("invalid session ID")
}

// client salt is sent back to the client in the intent response
clientSalt, err := randomHex(att, 12)
if err != nil {
return nil, err
}
// server salt is sent to the WaaS API and stored in the auth session
serverSalt, err := randomHex(att, 12)
if err != nil {
return nil, err
}

// clientAnswer is the value that we expect the client to produce
clientAnswer := hexutil.Encode(ethcrypto.Keccak256([]byte(clientSalt + verifier)))

// serverAnswer is the value we compare the answer against during verification
serverAnswer := hexutil.Encode(ethcrypto.Keccak256([]byte(serverSalt + clientAnswer)))

verifCtx = &proto.VerificationContext{
ProjectID: tnt.ProjectID,
SessionID: sessionID,
IdentityType: proto.IdentityType_Guest,
Verifier: verifier,
Challenge: &serverSalt, // the SERVER salt is a challenge in server's context
Answer: &serverAnswer, // the final answer, after hashing clientAnswer with serverSalt
ExpiresAt: time.Now().Add(5 * time.Minute),
}
if err := storeFn(ctx, verifCtx); err != nil {
return nil, err
}

// Client should combine the challenge from the response with the session ID and hash it.
// The resulting value is the clientAnswer that is then send with the openSession intent and passed to Verify.
res := &intents.IntentResponseAuthInitiated{
SessionID: verifCtx.SessionID,
IdentityType: intents.IdentityType_Guest,
ExpiresIn: int(verifCtx.ExpiresAt.Sub(time.Now()).Seconds()),
Challenge: &clientSalt, // the CLIENT salt is a challenge in client's context
}
return res, nil
}

func (p AuthProvider) Verify(ctx context.Context, verifCtx *proto.VerificationContext, sessionID string, answer string) (proto.Identity, error) {
if verifCtx == nil {
return proto.Identity{}, fmt.Errorf("verification context not found")
}

if verifCtx.Challenge == nil || verifCtx.Answer == nil {
return proto.Identity{}, fmt.Errorf("verification context did not have challenge/answer")
}

if verifCtx.Verifier != sessionID {
return proto.Identity{}, fmt.Errorf("invalid session ID")
}

// challenge here is the server salt; combined with the client's answer and hashed it produces the serverAnswer
serverAnswer := hexutil.Encode(ethcrypto.Keccak256([]byte(*verifCtx.Challenge + answer)))
if serverAnswer != *verifCtx.Answer {
return proto.Identity{}, fmt.Errorf("incorrect answer")
}

ident := proto.Identity{
Type: proto.IdentityType_Guest,
Subject: sessionID,
}
return ident, nil
}

func (p AuthProvider) ValidateTenant(ctx context.Context, tenant *proto.TenantData) error {
return nil
}

func randomHex(source io.Reader, n int) (string, error) {
b := make([]byte, n)
if _, err := source.Read(b); err != nil {
return "", err
}
return hexutil.Encode(b), nil
}
216 changes: 216 additions & 0 deletions rpc/auth/oidc/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
package oidc

import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"time"

"github.com/0xsequence/ethkit/go-ethereum/common/hexutil"
ethcrypto "github.com/0xsequence/ethkit/go-ethereum/crypto"
"github.com/0xsequence/go-sequence/intents"
"github.com/0xsequence/waas-authenticator/proto"
"github.com/0xsequence/waas-authenticator/rpc/auth"
"github.com/0xsequence/waas-authenticator/rpc/tenant"
"github.com/0xsequence/waas-authenticator/rpc/tracing"
"github.com/goware/cachestore"
"github.com/goware/cachestore/cachestorectl"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/lestrrat-go/jwx/v2/jwt"
"golang.org/x/sync/errgroup"
)

type AuthProvider struct {
client HTTPClient
store cachestore.Store[jwk.Key]
}

func NewAuthProvider(cacheBackend cachestore.Backend, client HTTPClient) (auth.Provider, error) {
if client == nil {
client = http.DefaultClient
}
store, err := cachestorectl.Open[jwk.Key](cacheBackend)
if err != nil {
return nil, err
}
return &AuthProvider{
client: client,
store: store,
}, nil
}

func (*AuthProvider) IsEnabled(tenant *proto.TenantData) bool {
return len(tenant.OIDCProviders) > 0
}

func (p *AuthProvider) InitiateAuth(
ctx context.Context,
verifCtx *proto.VerificationContext,
verifier string,
sessionID string,
storeFn auth.StoreVerificationContextFn,
) (*intents.IntentResponseAuthInitiated, error) {
tnt := tenant.FromContext(ctx)

if verifCtx != nil {
return nil, fmt.Errorf("cannot reuse an old ID token")
}

tokHash, expiresAt, err := p.extractVerifier(verifier)
if err != nil {
return nil, err
}

if time.Now().After(expiresAt) {
return nil, fmt.Errorf("token expired")
}

answer := tokHash
challenge := fmt.Sprintf("exp=%d", expiresAt.Unix())

verifCtx = &proto.VerificationContext{
ProjectID: tnt.ProjectID,
SessionID: sessionID,
IdentityType: proto.IdentityType_OIDC,
Verifier: verifier,
Answer: &answer,
Challenge: &challenge,
ExpiresAt: expiresAt,
}
if err := storeFn(ctx, verifCtx); err != nil {
return nil, err
}

res := &intents.IntentResponseAuthInitiated{
SessionID: verifCtx.SessionID,
IdentityType: intents.IdentityType_OIDC,
ExpiresIn: int(verifCtx.ExpiresAt.Sub(time.Now()).Seconds()),
}
return res, nil
}

func (p *AuthProvider) Verify(ctx context.Context, verifCtx *proto.VerificationContext, sessionID string, answer string) (ident proto.Identity, err error) {
if verifCtx == nil {
return proto.Identity{}, fmt.Errorf("auth session not found")
}

tok, err := jwt.Parse([]byte(answer), jwt.WithVerify(false), jwt.WithValidate(false))
if err != nil {
return proto.Identity{}, fmt.Errorf("parse JWT: %w", err)
}

issuer := normalizeIssuer(tok.Issuer())
idp := getOIDCProvider(ctx, issuer)
if idp == nil {
return proto.Identity{}, fmt.Errorf("issuer %q not valid for this tenant", issuer)
}

expectedHash := hexutil.Encode(ethcrypto.Keccak256([]byte(answer)))
if *verifCtx.Answer != expectedHash {
return proto.Identity{}, fmt.Errorf("invalid token hash")
}

if err := p.verifyChallenge(tok, *verifCtx.Challenge); err != nil {
return proto.Identity{}, fmt.Errorf("verify challenge: %w", err)
}

ks := &operationKeySet{
ctx: ctx,
iss: issuer,
store: p.store,
getKeySet: p.GetKeySet,
}

if _, err := jws.Verify([]byte(answer), jws.WithKeySet(ks, jws.WithMultipleKeysPerKeyID(false))); err != nil {
return proto.Identity{}, fmt.Errorf("signature verification: %w", err)
}

validateOptions := []jwt.ValidateOption{
jwt.WithValidator(withIssuer(idp.Issuer)),
jwt.WithAcceptableSkew(10 * time.Second),
jwt.WithValidator(withAudience(idp.Audience)),
}

if err := jwt.Validate(tok, validateOptions...); err != nil {
return proto.Identity{}, fmt.Errorf("JWT validation: %w", err)
}

identity := proto.Identity{
Type: proto.IdentityType_OIDC,
Issuer: issuer,
Subject: tok.Subject(),
Email: getEmailFromToken(tok),
}
return identity, nil
}

func (p *AuthProvider) ValidateTenant(ctx context.Context, tenant *proto.TenantData) error {
var wg errgroup.Group
ctx, cancel := context.WithCancel(ctx)
defer cancel()

for i, provider := range tenant.OIDCProviders {
provider := provider

if provider.Issuer == "" {
return fmt.Errorf("provider %d: empty issuer", i)
}

if len(provider.Audience) < 1 {
return fmt.Errorf("provider %d: at least one audience is required", i)
}

wg.Go(func() error {
if _, err := p.GetKeySet(ctx, provider.Issuer); err != nil {
return err
}
return nil
})
}

return wg.Wait()
}

func (p *AuthProvider) GetKeySet(ctx context.Context, issuer string) (set jwk.Set, err error) {
jwksURL, err := fetchJWKSURL(ctx, p.client, issuer)
if err != nil {
return nil, fmt.Errorf("fetch issuer keys: %w", err)
}

keySet, err := jwk.Fetch(ctx, jwksURL, jwk.WithHTTPClient(tracing.WrapClientWithContext(ctx, p.client)))
if err != nil {
return nil, fmt.Errorf("fetch issuer keys: %w", err)
}
return keySet, nil
}

func (p *AuthProvider) extractVerifier(verifier string) (tokHash string, expiresAt time.Time, err error) {
parts := strings.SplitN(verifier, ";", 2)

tokHash = parts[0]
exp, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return "", time.Time{}, fmt.Errorf("parse exp: %w", err)
}
expiresAt = time.Unix(exp, 0)

return tokHash, expiresAt, nil
}

func (p *AuthProvider) verifyChallenge(tok jwt.Token, challenge string) error {
s := strings.TrimPrefix(challenge, "exp=")
exp, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return fmt.Errorf("parse exp: %w", err)
}
expiresAt := time.Unix(exp, 0)

if !tok.Expiration().Equal(expiresAt) {
return fmt.Errorf("invalid exp claim")
}

return nil
}
Loading

0 comments on commit b117700

Please sign in to comment.