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

oauth: add sign-in with apple support (fixes #110) #243

Merged
merged 6 commits into from
Apr 3, 2024

Conversation

AlexCuse
Copy link
Contributor

@AlexCuse AlexCuse commented Feb 22, 2024

Implements an oauth provider for sign-in with apple. Requires some
additional flexibility in oauth credentials / providers:

  • Include a map of additional data in credentials.
  • Allow providers to override secret behavior - configured secret in
    apple credentials is a private key used to sign a secret calculated
    at runtime, in this case a JWT that includes additional data as claims.
  • Allow providers to accept returns as HTTP POST instead of GET.
  • Allow providers to add additional oauth options to authorization
    request.

@AlexCuse
Copy link
Contributor Author

AlexCuse commented Feb 24, 2024

One thing I noticed so far trying to test is that Apple doesn't like including the scopes in the auth request unless response_mode=form_post is specified. Should be pretty straightforward but guess we'll need a post handler for oauth return 😢

@AlexCuse AlexCuse force-pushed the apple-oauth branch 2 times, most recently from 0159567 to 893e82d Compare March 22, 2024 09:56
@AlexCuse
Copy link
Contributor Author

One thing I noticed so far trying to test is that Apple doesn't like including the scopes in the auth request unless response_mode=form_post is specified. Should be pretty straightforward but guess we'll need a post handler for oauth return 😢

We just needed to redirect POSTs to the existing handler and allow providers to override the method for routing.

Comment on lines 23 to 37
teamID, keyID, expiresIn, constructErr := apple.ExtractCredentialData(credentials.Additional)
if constructErr != nil {
return nil, fmt.Errorf("apple credentials: %w", constructErr)
}

signingKey, publicKey, constructErr := apple.ParsePrivateKey(credentials.SigningKey, keyID)
if constructErr != nil {
return nil, fmt.Errorf("failed to parse signing key: %w", constructErr)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Would be possible to move apple. ExtractCredentialData and apple.ParsePrivateKey to that scope?

The AppleProvider will not function without the necessary credentials and keys. By moving this logic to a higher scope, it will be clearer what needs to be modified, thereby preventing substantial changes to the API. Those values can be stored in the additional hash map.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Think I prefer leaving it in provider initialization and keeping the app as dumb as possible.

"golang.org/x/oauth2"
)

func testSigningKey(t *testing.T) (jose.SigningKey, string, *rsa.PrivateKey) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This function will be accessible within the apple package. It might be more effective to encapsulate it within TestGetAppleIDTokenClaims by creating a variable of the function type.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Functions in _test files aren't callable like that but the change is easy enough to make.

})
}

func Test_validateExp(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

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

There is a typo in the test case name here, please update to use MixedCaps.

MethodOverride string
VerificationKey jose.JSONWebKey
}

// NewProvider returns a properly configured Provider
func NewProvider(config *oauth2.Config, userInfo UserInfoFetcher, signingKey jose.SigningKey) *Provider {
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of creating NewProviderWithOverrides, we could employ the functional options pattern. This approach would centralize the logic in one place, thereby enhancing maintainability.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea!

"golang.org/x/oauth2"
)

func TestProvider_Secret(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

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

There is a typo here in the name of test case "_".

for providerName := range app.OauthProviders {
for providerName, provider := range app.OauthProviders {
var returnRoute *route.Route
if provider.ReturnMethod() == http.MethodPost {
Copy link
Contributor

Choose a reason for hiding this comment

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

For the sake of consistency, I suggest we implement both HTTP methods for all OAuth providers instead of making it conditional.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Think I prefer keeping the surface area as small as possible here.

Copy link
Member

Choose a reason for hiding this comment

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

consistency is one of my favorite words, and POST is more secure than the default GET, but it's hard to argue with minimizing the surface area.

Implements an oauth provider for sign-in with apple. Requires some
additional flexibility in oauth credentials / providers:

- Include a map of additional data in credentials.
- Allow providers to override secret behavior - configured secret in
apple credentials is a private key used to sign a secret calculated
at runtime, in this case a JWT that includes additional data as claims.
- Allow providers to accept returns as HTTP POST instead of GET.
- Allow providers to add additional oauth options to authorization
request.
Copy link
Member

@cainlevy cainlevy left a comment

Choose a reason for hiding this comment

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

I can't reasonably verify the Apple-specific details, so I tried to focus on changes outside of lib/oauth/apple as well as the JWT validation process.

Would you take a look at the comment on extractUserFromClaims specifically? The rest is aesthetic, I think.

docs/config.md Outdated Show resolved Hide resolved
lib/oauth/credentials.go Outdated Show resolved Hide resolved
lib/oauth/credentials.go Outdated Show resolved Hide resolved
for providerName := range app.OauthProviders {
for providerName, provider := range app.OauthProviders {
var returnRoute *route.Route
if provider.ReturnMethod() == http.MethodPost {
Copy link
Member

Choose a reason for hiding this comment

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

consistency is one of my favorite words, and POST is more secure than the default GET, but it's hard to argue with minimizing the surface area.

lib/oauth/apple/idtoken.go Outdated Show resolved Hide resolved
lib/oauth/apple/idtoken.go Outdated Show resolved Hide resolved
lib/oauth/apple/idtoken.go Outdated Show resolved Hide resolved
return claims, nil
}

func extractUserFromClaims(claims map[string]interface{}, clientID string) (string, string, error) {
Copy link
Member

Choose a reason for hiding this comment

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

can we use https://pkg.go.dev/github.com/go-jose/go-jose/v3/jwt#Claims.Validate for the standard iss, aud, and exp claims? then our code can focus on our unique values like sub and email.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will see what I can do here. The problem is if we read into jwt.Claims we lose visibility into sub and email - but there might be something we can do with a custom Claims type in the apple package that embeds / implements its own json unmarshaler that lets us leverage it.

Copy link
Member

Choose a reason for hiding this comment

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

i'd normally verify claims like iss, aud, and exp during the decoding step. is it easier to get at sub and email if you move the others?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I kinda like how this turned out let me know what you think 95e1df3

Comment on lines 38 to 47
type mockKeyStore struct {
keys map[string]*rsa.PublicKey
}

func (ks *mockKeyStore) get(keyID string) (*rsa.PublicKey, error) {
if key, ok := ks.keys[keyID]; ok {
return key, nil
}
return nil, &keyNotFoundError{keyID: keyID}
}
Copy link
Member

Choose a reason for hiding this comment

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

should mockKeyStore be in package apple_test?

@@ -0,0 +1,291 @@
package apple
Copy link
Member

Choose a reason for hiding this comment

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

should this be package apple_test?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Its testing unexported functions that I'm not sure we need to export - but I did move the mock keyStore into the _test file.

Moving to apple/test probably works as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually this got a lot more practical when I implemented that claims suggestion.

Copy link
Member

@cainlevy cainlevy left a comment

Choose a reason for hiding this comment

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

claims validation looks good to me!

return fmt.Errorf("missing claim 'iat'")
}

// is default 1m leeway OK here?
Copy link
Member

Choose a reason for hiding this comment

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

i'd think so. if there's some network delay when the user returns to authn, this leeway should handle it. if the token is more than 1m too old, then someone's clock is out of sync and they're going to have bigger problems.

Comment on lines +36 to +39
return c.Claims.Validate(jwt.Expected{
Issuer: BaseURL,
Audience: jwt.Audience{clientID},
})
Copy link
Member

Choose a reason for hiding this comment

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

i checked to confirm that it will validate exp and iat against time.Now() by default. 👍

@AlexCuse AlexCuse merged commit 3d109a8 into keratin:main Apr 3, 2024
2 checks passed
@AlexCuse AlexCuse deleted the apple-oauth branch April 3, 2024 01:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants