-
Notifications
You must be signed in to change notification settings - Fork 2
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
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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