Skip to content

Commit

Permalink
user: claims and parsing for invitations
Browse files Browse the repository at this point in the history
  • Loading branch information
Joe Bowers committed Nov 11, 2015
1 parent ca9227f commit 468c1b8
Show file tree
Hide file tree
Showing 4 changed files with 204 additions and 34 deletions.
36 changes: 18 additions & 18 deletions user/email_verification.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.

This comment has been minimized.

Copy link
@bobbyrullo

bobbyrullo Nov 13, 2015

Contributor

typo: addwress

This comment has been minimized.

Copy link
@joeatwork

joeatwork Nov 17, 2015

Contributor

Fixed in 2136b17

// 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())
Expand All @@ -29,17 +31,26 @@ 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
}
if !ok || email == "" {
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
}
Expand All @@ -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 {
Expand Down
59 changes: 59 additions & 0 deletions user/invitation.go
Original file line number Diff line number Diff line change
@@ -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 {

This comment has been minimized.

Copy link
@bobbyrullo

bobbyrullo Nov 13, 2015

Contributor

Add docs explaining what an invitation is

This comment has been minimized.

Copy link
@joeatwork

joeatwork Nov 17, 2015

Contributor

fixed in b9d4e79

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
}
113 changes: 113 additions & 0 deletions user/invitation_test.go
Original file line number Diff line number Diff line change
@@ -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: "[email protected]"}
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)

This comment has been minimized.

Copy link
@bobbyrullo

bobbyrullo Nov 13, 2015

Contributor

Why not create the invitations inline with the tests? The way it is now I have to jump back and forth

This comment has been minimized.

Copy link
@joeatwork

joeatwork Nov 17, 2015

Contributor

Fixed in 06635f1

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: "[email protected]"}, 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)
}
}
}
30 changes: 14 additions & 16 deletions user/password.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,37 +225,35 @@ 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
}

if _, err := url.Parse(cb); err != nil {
return PasswordReset{}, fmt.Errorf("callback URL not parseable: %v", cb)
}

pw, ok, err := claims.StringClaim(ClaimPasswordResetPassword)
pw, ok, err := tokenClaims.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)
cb, ok, err := tokenClaims.Claims.StringClaim(ClaimPasswordResetCallback)
if err != nil {
return PasswordReset{}, err
}

return verifyPasswordResetClaims(tokenClaims.Claims)
if _, err := url.Parse(cb); err != nil {
return PasswordReset{}, fmt.Errorf("callback URL not parseable: %v", cb)
}

return PasswordReset{tokenClaims.Claims}, nil
}

func (e PasswordReset) UserID() string {
Expand Down

0 comments on commit 468c1b8

Please sign in to comment.