8cc5fbf859
* first stab at login.gov provider * fixing bugs now that I think I understand things better * fixing up dependencies * remove some debug stuff * Fixing all dependencies to point at my fork * forgot to hit save on the github rehome here * adding options for setting keys and so on, use JWT workflow instead of PKCE * forgot comma * was too aggressive with search/replace * need JWTKey to be byte array * removed custom refresh stuff * do our own custom jwt claim and store it in the normal session store * golang json types are strange * I have much to learn about golang * fix time and signing key * add http lib * fixed claims up since we don't need custom claims * add libs * forgot ioutil * forgot ioutil * moved back to pusher location * changed proxy github location back so that it builds externally, fixed up []byte stuff, removed client_secret if we are using login.gov * update dependencies * do JWTs properly * finished oidc flow, fixed up tests to work better * updated comments, added test that we set expiresOn properly * got confused with header and post vs get * clean up debug and test dir * add login.gov to README, remove references to my repo * forgot to remove un-needed code * can use sample_key* instead of generating your own * updated changelog * apparently golint wants comments like this * linter wants non-standard libs in a separate grouping * Update options.go Co-Authored-By: timothy-spencer <timothy.spencer@gsa.gov> * Update options.go Co-Authored-By: timothy-spencer <timothy.spencer@gsa.gov> * remove sample_key, improve comments related to client-secret, fix changelog related to PR feedback * github doesn't seem to do gofmt when merging. :-) * update CODEOWNERS * check the nonce * validate the JWT fully * forgot to add pubjwk-url to README * unexport the struct * fix up the err masking that travis found * update nonce comment by request of @JoelSpeed * argh. Thought I'd formatted the merge properly, but apparently not. * fixed test to not fail if the query time was greater than zero
276 lines
6.8 KiB
Go
276 lines
6.8 KiB
Go
package providers
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rsa"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/dgrijalva/jwt-go"
|
|
"gopkg.in/square/go-jose.v2"
|
|
)
|
|
|
|
// LoginGovProvider represents an OIDC based Identity Provider
|
|
type LoginGovProvider struct {
|
|
*ProviderData
|
|
|
|
// TODO (@timothy-spencer): Ideally, the nonce would be in the session state, but the session state
|
|
// is created only upon code redemption, not during the auth, when this must be supplied.
|
|
Nonce string
|
|
AcrValues string
|
|
JWTKey *rsa.PrivateKey
|
|
PubJWKURL *url.URL
|
|
}
|
|
|
|
// For generating a nonce
|
|
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
|
|
|
func randSeq(n int) string {
|
|
b := make([]rune, n)
|
|
for i := range b {
|
|
b[i] = letters[rand.Intn(len(letters))]
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// NewLoginGovProvider initiates a new LoginGovProvider
|
|
func NewLoginGovProvider(p *ProviderData) *LoginGovProvider {
|
|
p.ProviderName = "login.gov"
|
|
|
|
if p.LoginURL == nil || p.LoginURL.String() == "" {
|
|
p.LoginURL = &url.URL{
|
|
Scheme: "https",
|
|
Host: "secure.login.gov",
|
|
Path: "/openid_connect/authorize",
|
|
}
|
|
}
|
|
if p.RedeemURL == nil || p.RedeemURL.String() == "" {
|
|
p.RedeemURL = &url.URL{
|
|
Scheme: "https",
|
|
Host: "secure.login.gov",
|
|
Path: "/api/openid_connect/token",
|
|
}
|
|
}
|
|
if p.ProfileURL == nil || p.ProfileURL.String() == "" {
|
|
p.ProfileURL = &url.URL{
|
|
Scheme: "https",
|
|
Host: "secure.login.gov",
|
|
Path: "/api/openid_connect/userinfo",
|
|
}
|
|
}
|
|
if p.Scope == "" {
|
|
p.Scope = "email openid"
|
|
}
|
|
|
|
return &LoginGovProvider{
|
|
ProviderData: p,
|
|
Nonce: randSeq(32),
|
|
}
|
|
}
|
|
|
|
type loginGovCustomClaims struct {
|
|
Acr string `json:"acr"`
|
|
Nonce string `json:"nonce"`
|
|
Email string `json:"email"`
|
|
EmailVerified bool `json:"email_verified"`
|
|
GivenName string `json:"given_name"`
|
|
FamilyName string `json:"family_name"`
|
|
Birthdate string `json:"birthdate"`
|
|
AtHash string `json:"at_hash"`
|
|
CHash string `json:"c_hash"`
|
|
jwt.StandardClaims
|
|
}
|
|
|
|
// checkNonce checks the nonce in the id_token
|
|
func checkNonce(idToken string, p *LoginGovProvider) (err error) {
|
|
token, err := jwt.ParseWithClaims(idToken, &loginGovCustomClaims{}, func(token *jwt.Token) (interface{}, error) {
|
|
resp, myerr := http.Get(p.PubJWKURL.String())
|
|
if myerr != nil {
|
|
return nil, myerr
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
myerr = fmt.Errorf("got %d from %q", resp.StatusCode, p.PubJWKURL.String())
|
|
return nil, myerr
|
|
}
|
|
body, myerr := ioutil.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if myerr != nil {
|
|
return nil, myerr
|
|
}
|
|
|
|
var pubkeys jose.JSONWebKeySet
|
|
myerr = json.Unmarshal(body, &pubkeys)
|
|
if myerr != nil {
|
|
return nil, myerr
|
|
}
|
|
pubkey := pubkeys.Keys[0]
|
|
|
|
return pubkey.Key, nil
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
claims := token.Claims.(*loginGovCustomClaims)
|
|
if claims.Nonce != p.Nonce {
|
|
err = fmt.Errorf("nonce validation failed")
|
|
return
|
|
}
|
|
return
|
|
}
|
|
|
|
func emailFromUserInfo(accessToken string, userInfoEndpoint string) (email string, err error) {
|
|
// query the user info endpoint for user attributes
|
|
var req *http.Request
|
|
req, err = http.NewRequest("GET", userInfoEndpoint, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var body []byte
|
|
body, err = ioutil.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
if resp.StatusCode != 200 {
|
|
err = fmt.Errorf("got %d from %q %s", resp.StatusCode, userInfoEndpoint, body)
|
|
return
|
|
}
|
|
|
|
// parse the user attributes from the data we got and make sure that
|
|
// the email address has been validated.
|
|
var emailData struct {
|
|
Email string `json:"email"`
|
|
EmailVerified bool `json:"email_verified"`
|
|
}
|
|
err = json.Unmarshal(body, &emailData)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if emailData.Email == "" {
|
|
err = fmt.Errorf("missing email")
|
|
return
|
|
}
|
|
email = emailData.Email
|
|
if !emailData.EmailVerified {
|
|
err = fmt.Errorf("email %s not listed as verified", email)
|
|
return
|
|
}
|
|
return
|
|
}
|
|
|
|
// Redeem exchanges the OAuth2 authentication token for an ID token
|
|
func (p *LoginGovProvider) Redeem(redirectURL, code string) (s *SessionState, err error) {
|
|
if code == "" {
|
|
err = errors.New("missing code")
|
|
return
|
|
}
|
|
|
|
claims := &jwt.StandardClaims{
|
|
Issuer: p.ClientID,
|
|
Subject: p.ClientID,
|
|
Audience: p.RedeemURL.String(),
|
|
ExpiresAt: int64(time.Now().Add(time.Duration(5 * time.Minute)).Unix()),
|
|
Id: randSeq(32),
|
|
}
|
|
token := jwt.NewWithClaims(jwt.GetSigningMethod("RS256"), claims)
|
|
ss, err := token.SignedString(p.JWTKey)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
params := url.Values{}
|
|
params.Add("client_assertion", ss)
|
|
params.Add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
|
|
params.Add("code", code)
|
|
params.Add("grant_type", "authorization_code")
|
|
|
|
var req *http.Request
|
|
req, err = http.NewRequest("POST", p.RedeemURL.String(), bytes.NewBufferString(params.Encode()))
|
|
if err != nil {
|
|
return
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
var resp *http.Response
|
|
resp, err = http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var body []byte
|
|
body, err = ioutil.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
if resp.StatusCode != 200 {
|
|
err = fmt.Errorf("got %d from %q %s", resp.StatusCode, p.RedeemURL.String(), body)
|
|
return
|
|
}
|
|
|
|
// Get the token from the body that we got from the token endpoint.
|
|
var jsonResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
IDToken string `json:"id_token"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
}
|
|
err = json.Unmarshal(body, &jsonResponse)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// check nonce here
|
|
err = checkNonce(jsonResponse.IDToken, p)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Get the email address
|
|
var email string
|
|
email, err = emailFromUserInfo(jsonResponse.AccessToken, p.ProfileURL.String())
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Store the data that we found in the session state
|
|
s = &SessionState{
|
|
AccessToken: jsonResponse.AccessToken,
|
|
IDToken: jsonResponse.IDToken,
|
|
ExpiresOn: time.Now().Add(time.Duration(jsonResponse.ExpiresIn) * time.Second).Truncate(time.Second),
|
|
Email: email,
|
|
}
|
|
return
|
|
}
|
|
|
|
// GetLoginURL overrides GetLoginURL to add login.gov parameters
|
|
func (p *LoginGovProvider) GetLoginURL(redirectURI, state string) string {
|
|
var a url.URL
|
|
a = *p.LoginURL
|
|
params, _ := url.ParseQuery(a.RawQuery)
|
|
params.Set("redirect_uri", redirectURI)
|
|
params.Set("approval_prompt", p.ApprovalPrompt)
|
|
params.Add("scope", p.Scope)
|
|
params.Set("client_id", p.ClientID)
|
|
params.Set("response_type", "code")
|
|
params.Add("state", state)
|
|
params.Add("acr_values", p.AcrValues)
|
|
params.Add("nonce", p.Nonce)
|
|
a.RawQuery = params.Encode()
|
|
return a.String()
|
|
}
|