Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Commit/reveal OIDC flow #47

Merged
merged 4 commits into from
Jun 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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{}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have any tests for those new providers?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some basic success-scenario tests. Planning to expand them much further later on


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
Loading