From cc58a3da7d647de8520e33dc4356672d2ed1a366 Mon Sep 17 00:00:00 2001 From: Mike Crute Date: Tue, 16 Nov 2021 14:46:24 -0800 Subject: Import of source code --- auth/github/github.go | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++ auth/jwt.go | 123 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 auth/github/github.go create mode 100644 auth/jwt.go (limited to 'auth') diff --git a/auth/github/github.go b/auth/github/github.go new file mode 100644 index 0000000..b403296 --- /dev/null +++ b/auth/github/github.go @@ -0,0 +1,127 @@ +package github + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strings" +) + +type apiLoginResponse struct { + Username string `json:"login"` +} + +type GitHubToken struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +type GitHubAuthenticator struct { + ClientId string + ClientSecret string +} + +// GetAuthRedirect returns a redirect URL and a state parameter that should be +// stored in the user's browser to correlate the authentication result and +// request. +func (a *GitHubAuthenticator) GetAuthRedirect() (string, string) { + sr := make([]byte, 32) + if _, err := rand.Read(sr); err != nil { + panic(err) // This should only happen if no entropy available + } + random := base64.URLEncoding.EncodeToString(sr) + + return (&url.URL{ + Scheme: "https", + Host: "github.com", + Path: "/login/oauth/authorize", + RawQuery: url.Values{ + "client_id": []string{a.ClientId}, + "scope": []string{"read:user"}, + "state": []string{random}, + "allow_signup": []string{"false"}, + }.Encode(), + }).String(), random +} + +// GetTokens returns the GitHub Oauth tokens from the service given a token +// request code. The returned tokens are used for authentication to the GitHub +// API. +// +// This relies on a GitHub Oauth application on the GitHub side to act as the +// client for these tokens. +func (a *GitHubAuthenticator) GetTokens(code string) (*GitHubToken, error) { + r, err := (&http.Client{}).Do(&http.Request{ + Method: http.MethodPost, + URL: &url.URL{ + Scheme: "https", + Host: "github.com", + Path: "/login/oauth/access_token", + }, + Header: http.Header{ + "Content-Type": []string{"application/x-www-form-urlencoded"}, + "Accept": []string{"application/json"}, + }, + Body: ioutil.NopCloser(strings.NewReader(url.Values{ + "client_id": []string{a.ClientId}, + "client_secret": []string{a.ClientSecret}, + "code": []string{code}, + }.Encode())), + }) + if err != nil { + return nil, err + } + defer r.Body.Close() + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + return nil, err + } + + var ret GitHubToken + if err = json.Unmarshal(body, &ret); err != nil { + return nil, err + } + + return &ret, nil +} + +// GetUsernameWithToken returns the authenticated user's GitHub username given +// an Oauth auth token. +func (a *GitHubAuthenticator) GetUsernameWithToken(accessToken string) (string, error) { + r, err := (&http.Client{}).Do(&http.Request{ + Method: http.MethodGet, + URL: &url.URL{ + Scheme: "https", + Host: "api.github.com", + Path: "/user", + }, + Header: http.Header{ + "Authorization": []string{fmt.Sprintf("token %s", accessToken)}, + }, + }) + if err != nil { + return "", err + } + defer r.Body.Close() + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + return "", err + } + + var data apiLoginResponse + if err = json.Unmarshal(body, &data); err != nil { + return "", err + } + + if data.Username == "" { + return "", fmt.Errorf("No user returned in GitHub response") + } + + return data.Username, nil +} diff --git a/auth/jwt.go b/auth/jwt.go new file mode 100644 index 0000000..c65cf39 --- /dev/null +++ b/auth/jwt.go @@ -0,0 +1,123 @@ +package auth + +import ( + "context" + "fmt" + "time" + + "code.crute.us/mcrute/cloud-identity-broker/app/models" + + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) + +const keyIdHeader = jose.HeaderKey("kid") + +type JWTManager struct { + Store models.UserStore + Audience string + TokenExpires time.Duration +} + +// Validate performs validate of a JWT for authentication. If the +// authentication was successful it will return the user model for the +// authenticated user. +// +// JWTs are signed by a per-user key on the service side. These keys may be +// generated automatically when issuing a token to the user or, in the case of +// service accounts, they may be generated by the user and validated on the +// service side with the user's public key. +func (m *JWTManager) Validate(j string) (*models.User, error) { + ctx := context.Background() + + parsed, err := jwt.ParseSigned(j) + if err != nil { + return nil, err + } + + // With a single signature there should be exactly one header. Without that + // we can't get the key id which means we won't be able to get the key and + // validate the signature. + if len(parsed.Headers) != 1 { + return nil, fmt.Errorf("Expected exactly 1 JWT header, got %d", len(parsed.Headers)) + } + + kid := parsed.Headers[0].KeyID + if kid == "" { + return nil, fmt.Errorf("No key ID in token header") + } + + // We need the subject claim to lookup the user so we can get their key and + // validate the token signature. + untrustedClaims := jwt.Claims{} + if err := parsed.UnsafeClaimsWithoutVerification(&untrustedClaims); err != nil { + return nil, err + } + + user, err := m.Store.Get(ctx, untrustedClaims.Subject) + if err != nil { + return nil, err + } + + key := user.GetKey(kid) // Will not return invalid keys + if key == nil { + return nil, fmt.Errorf("No key in user record for key id %s", kid) + } + + claims := jwt.Claims{} + if err = parsed.Claims(key.PublicKey, &claims); err != nil { + return nil, err + } + + if err = claims.Validate(jwt.Expected{ + Audience: jwt.Audience{m.Audience}, + Time: time.Now(), // +/- 1 minute + }); err != nil { + return nil, err + } + + // If we made it here then the user matches the public key used to sign the + // token and the claims are verified so just return the user record we + // fetched earlier. + + return user, nil +} + +// CreateForUser creates a new JWT based on the passed user. In some cases it +// may also need to generate a new SessionKey for the purpose of encrypting +// those JWTs. Callers should be sure to save the returned SessionKey, if there +// is one, to the user when setting the token otherwise the token signature +// will not be able to be validated on subsequent requests. +func (m *JWTManager) CreateForUser(u *models.User) (string, *models.SessionKey, error) { + pk, err := models.GenerateSessionKey(m.TokenExpires) + if err != nil { + return "", nil, err + } + + // The ExtraHeaders bit is kind of an ugly hack but there's no way in the + // official API to set the KeyID for single-signer tokens. However, if the + // magic "kid" label is set in the extra headers it will be set as the + // KeyID in the header when that's deserialized. + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.ES256, + Key: pk.PrivateKey, + }, &jose.SignerOptions{ + ExtraHeaders: map[jose.HeaderKey]interface{}{ + keyIdHeader: pk.KeyId, + }, + }) + if err != nil { + return "", nil, err + } + + now := time.Now() + j, err := jwt.Signed(signer).Claims(jwt.Claims{ + Issuer: m.Audience, + Subject: u.Username, + Audience: jwt.Audience{m.Audience}, + Expiry: jwt.NewNumericDate(now.Add(m.TokenExpires)), + IssuedAt: jwt.NewNumericDate(now), + }).CompactSerialize() + + return j, pk, err +} -- cgit v1.2.3