From 468c1b8b5edfe806d768dbe5e9708ee05b0b3cfa Mon Sep 17 00:00:00 2001 From: Joe Bowers Date: Mon, 19 Oct 2015 10:24:55 -0700 Subject: [PATCH] user: claims and parsing for invitations --- user/email_verification.go | 36 ++++++------ user/invitation.go | 59 +++++++++++++++++++ user/invitation_test.go | 113 +++++++++++++++++++++++++++++++++++++ user/password.go | 44 +++++++-------- 4 files changed, 211 insertions(+), 41 deletions(-) create mode 100644 user/invitation.go create mode 100644 user/invitation_test.go diff --git a/user/email_verification.go b/user/email_verification.go index ab8204a1..0d0c8fdd 100644 --- a/user/email_verification.go +++ b/user/email_verification.go @@ -16,8 +16,10 @@ var ( clock = clockwork.NewRealClock() ) -// NewEmailVerification creates an object which can be sent to a user in serialized form to verify that they control an email address. -// The clientID is the ID of the registering user. The callback is where a user should land after verifying their email. +// NewEmailVerification creates an object which can be sent to a user +// in serialized form to verify that they control an email addwress. +// The clientID is the ID of the registering user. The callback is +// where a user should land after verifying their email. func NewEmailVerification(user User, clientID string, issuer url.URL, callback url.URL, expires time.Duration) EmailVerification { claims := oidc.NewClaims(issuer.String(), user.ID, clientID, clock.Now(), clock.Now().Add(expires)) claims.Add(ClaimEmailVerificationCallback, callback.String()) @@ -29,9 +31,18 @@ type EmailVerification struct { Claims jose.Claims } -// Assumes that parseAndVerifyTokenClaims has already been called on claims -func verifyEmailVerificationClaims(claims jose.Claims) (EmailVerification, error) { - email, ok, err := claims.StringClaim(ClaimEmailVerificationEmail) +// ParseAndVerifyEmailVerificationToken parses a string into a an +// EmailVerification, verifies the signature, and ensures that +// required claims are present. In addition to the usual claims +// required by the OIDC spec, "aud" and "sub" must be present as well +// as ClaimEmailVerificationCallback and ClaimEmailVerificationEmail. +func ParseAndVerifyEmailVerificationToken(token string, issuer url.URL, keys []key.PublicKey) (EmailVerification, error) { + tokenClaims, err := parseAndVerifyTokenClaims(token, issuer, keys) + if err != nil { + return EmailVerification{}, err + } + + email, ok, err := tokenClaims.Claims.StringClaim(ClaimEmailVerificationEmail) if err != nil { return EmailVerification{}, err } @@ -39,7 +50,7 @@ func verifyEmailVerificationClaims(claims jose.Claims) (EmailVerification, error return EmailVerification{}, fmt.Errorf("no %q claim", ClaimEmailVerificationEmail) } - cb, ok, err := claims.StringClaim(ClaimEmailVerificationCallback) + cb, ok, err := tokenClaims.Claims.StringClaim(ClaimEmailVerificationCallback) if err != nil { return EmailVerification{}, err } @@ -50,18 +61,7 @@ func verifyEmailVerificationClaims(claims jose.Claims) (EmailVerification, error return EmailVerification{}, fmt.Errorf("callback URL not parseable: %v", cb) } - return EmailVerification{claims}, nil -} - -// ParseAndVerifyEmailVerificationToken parses a string into a an EmailVerification, verifies the signature, and ensures that required claims are present. -// In addition to the usual claims required by the OIDC spec, "aud" and "sub" must be present as well as ClaimEmailVerificationCallback and ClaimEmailVerificationEmail. -func ParseAndVerifyEmailVerificationToken(token string, issuer url.URL, keys []key.PublicKey) (EmailVerification, error) { - tokenClaims, err := parseAndVerifyTokenClaims(token, issuer, keys) - if err != nil { - return EmailVerification{}, err - } - - return verifyEmailVerificationClaims(tokenClaims.Claims) + return EmailVerification{tokenClaims.Claims}, nil } func (e EmailVerification) UserID() string { diff --git a/user/invitation.go b/user/invitation.go new file mode 100644 index 00000000..b1dbbfb1 --- /dev/null +++ b/user/invitation.go @@ -0,0 +1,59 @@ +package user + +import ( + "fmt" + "net/url" + "time" + + "github.com/coreos/go-oidc/jose" + "github.com/coreos/go-oidc/key" + "github.com/coreos/go-oidc/oidc" +) + +func NewInvitation(user User, password Password, issuer url.URL, clientID string, callback url.URL, expires time.Duration) Invitation { + claims := oidc.NewClaims(issuer.String(), user.ID, clientID, clock.Now(), clock.Now().Add(expires)) + claims.Add(ClaimPasswordResetPassword, string(password)) + claims.Add(ClaimEmailVerificationEmail, user.Email) + claims.Add(ClaimInvitationCallback, callback.String()) + return Invitation{claims} +} + +type Invitation struct { + Claims jose.Claims +} + +func ParseAndVerifyInvitationToken(token string, issuer url.URL, keys []key.PublicKey) (Invitation, error) { + tokenClaims, err := parseAndVerifyTokenClaims(token, issuer, keys) + if err != nil { + return Invitation{}, err + } + + cb, ok, err := tokenClaims.Claims.StringClaim(ClaimInvitationCallback) + if err != nil { + return Invitation{}, err + } + if !ok || cb == "" { + return Invitation{}, fmt.Errorf("no %q claim", ClaimInvitationCallback) + } + if _, err := url.Parse(cb); err != nil { + return Invitation{}, fmt.Errorf("callback URL not parseable: %v", cb) + } + + pw, ok, err := tokenClaims.Claims.StringClaim(ClaimPasswordResetPassword) + if err != nil { + return Invitation{}, err + } + if !ok || pw == "" { + return Invitation{}, fmt.Errorf("no %q claim", ClaimPasswordResetPassword) + } + + email, ok, err := tokenClaims.Claims.StringClaim(ClaimEmailVerificationEmail) + if err != nil { + return Invitation{}, err + } + if !ok || email == "" { + return Invitation{}, fmt.Errorf("no %q claim", ClaimEmailVerificationEmail) + } + + return Invitation{tokenClaims.Claims}, nil +} diff --git a/user/invitation_test.go b/user/invitation_test.go new file mode 100644 index 00000000..b797a028 --- /dev/null +++ b/user/invitation_test.go @@ -0,0 +1,113 @@ +package user + +import ( + "net/url" + "testing" + "time" + + "github.com/kylelemons/godebug/pretty" + + "github.com/coreos/go-oidc/jose" + "github.com/coreos/go-oidc/key" +) + +func TestInvitationParseAndVerify(t *testing.T) { + issuer, _ := url.Parse("http://example.com") + notIssuer, _ := url.Parse("http://other.com") + client := "myclient" + user := User{ID: "1234", Email: "user@example.com"} + callback, _ := url.Parse("http://client.example.com") + expires := time.Hour * 3 + password := Password("Halloween is the best holiday") + privKey, _ := key.GeneratePrivateKey() + signer := privKey.Signer() + publicKeys := []key.PublicKey{*key.NewPublicKey(privKey.JWK())} + + goodInvitation := NewInvitation(user, password, *issuer, client, *callback, expires) + goodNoCB := NewInvitation(user, password, *issuer, client, *callback, expires) + expired := NewInvitation(user, password, *issuer, client, *callback, -expires) + wrongIssuer := NewInvitation(user, password, *notIssuer, client, *callback, expires) + noSub := NewInvitation(User{Email: "noid@noid.com"}, password, *issuer, client, *callback, expires) + noEmail := NewInvitation(User{ID: "JONNY_NO_EMAIL"}, password, *issuer, client, *callback, expires) + noPassword := NewInvitation(user, Password(""), *issuer, client, *callback, expires) + noClient := NewInvitation(user, password, *issuer, "", *callback, expires) + noClientNoCB := NewInvitation(user, password, *issuer, "", url.URL{}, expires) + + tests := []struct { + invite Invitation + wantErr bool + signer jose.Signer + }{ + { + invite: goodInvitation, + signer: signer, + wantErr: false, + }, + { + invite: goodNoCB, + signer: signer, + wantErr: false, + }, + { + invite: expired, + signer: signer, + wantErr: true, + }, + { + invite: wrongIssuer, + signer: signer, + wantErr: true, + }, + { + invite: noSub, + signer: signer, + wantErr: true, + }, + { + invite: noEmail, + signer: signer, + wantErr: true, + }, + { + invite: noPassword, + signer: signer, + wantErr: true, + }, + { + invite: noClient, + signer: signer, + wantErr: true, + }, + { + invite: noClientNoCB, + signer: signer, + wantErr: true, + }, + } + + for i, tt := range tests { + jwt, err := jose.NewSignedJWT(tt.invite.Claims, tt.signer) + if err != nil { + t.Fatalf("case %d: failed to generate JWT, error: %v", i, err) + } + token := jwt.Encode() + + parsed, err := ParseAndVerifyInvitationToken(token, *issuer, publicKeys) + + if tt.wantErr { + if err == nil { + t.Errorf("case %d: want no-nil error, got nil", i) + } + continue + } + + if err != nil { + t.Errorf("case %d: unexpected error: %v", i, err) + continue + } + + if diff := pretty.Compare(tt.invite, parsed); diff != "" { + t.Errorf("case %d: Compare(want, got): %v", i, diff) + } + } +} diff --git a/user/password.go b/user/password.go index 067c59a0..e97bf240 100644 --- a/user/password.go +++ b/user/password.go @@ -225,9 +225,26 @@ type PasswordReset struct { Claims jose.Claims } -// Assumes that parseAndVerifyTokenClaims has already been called on claims -func verifyPasswordResetClaims(claims jose.Claims) (PasswordReset, error) { - cb, ok, err := claims.StringClaim(ClaimPasswordResetCallback) +// ParseAndVerifyPasswordResetToken parses a string into a an +// PasswordReset, verifies the signature, and ensures that required +// claims are present. In addition to the usual claims required by +// the OIDC spec, "aud" and "sub" must be present as well as +// ClaimPasswordResetCallback and ClaimPasswordResetPassword. +func ParseAndVerifyPasswordResetToken(token string, issuer url.URL, keys []key.PublicKey) (PasswordReset, error) { + tokenClaims, err := parseAndVerifyTokenClaims(token, issuer, keys) + if err != nil { + return PasswordReset{}, err + } + + pw, ok, err := tokenClaims.Claims.StringClaim(ClaimPasswordResetPassword) + if err != nil { + return PasswordReset{}, err + } + if !ok || pw == "" { + return PasswordReset{}, fmt.Errorf("no %q claim", ClaimPasswordResetPassword) + } + + cb, ok, err := tokenClaims.Claims.StringClaim(ClaimPasswordResetCallback) if err != nil { return PasswordReset{}, err } @@ -236,26 +253,7 @@ func verifyPasswordResetClaims(claims jose.Claims) (PasswordReset, error) { return PasswordReset{}, fmt.Errorf("callback URL not parseable: %v", cb) } - pw, ok, err := claims.StringClaim(ClaimPasswordResetPassword) - if err != nil { - return PasswordReset{}, err - } - if !ok || pw == "" { - return PasswordReset{}, fmt.Errorf("no %q claim", ClaimPasswordResetPassword) - } - - return PasswordReset{claims}, nil -} - -// ParseAndVerifyPasswordResetToken parses a string into a an PasswordReset, verifies the signature, and ensures that required claims are present. -// In addition to the usual claims required by the OIDC spec, "aud" and "sub" must be present as well as ClaimPasswordResetCallback, ClaimPasswordResetEmail and ClaimPasswordResetPassword. -func ParseAndVerifyPasswordResetToken(token string, issuer url.URL, keys []key.PublicKey) (PasswordReset, error) { - tokenClaims, err := parseAndVerifyTokenClaims(token, issuer, keys) - if err != nil { - return PasswordReset{}, err - } - - return verifyPasswordResetClaims(tokenClaims.Claims) + return PasswordReset{tokenClaims.Claims}, nil } func (e PasswordReset) UserID() string {