-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
Showing
7 changed files
with
525 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.