// Package devicecode implements functionality for getting oauth tokens to authenticate // with GitHub on behalf of a user package devicecode import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "os/exec" "strings" "time" ) const ( oathGrantType = "urn:ietf:params:oauth:grant-type:device_code" deviceCodePath = "/login/device/code" accessTokenPath = "/login/oauth/access_token" // #nosec G101 - not an access token ) // Client holds basic information to get an oauth token from github type Client struct { BaseURL string ClientID string } type deviceCodeRequest struct { ClientID string `json:"client_id"` Scope string `json:"scope"` } type deviceCodeResponse struct { DeviceCode string `json:"device_code"` UserCode string `json:"user_code"` VerificationURI string `json:"verification_uri"` ExpiresIn int `json:"expires_in"` Interval int `json:"interval"` } type accessTokenRequest struct { ClientID string `json:"client_id"` DeviceCode string `json:"device_code"` GrantType string `json:"grant_type"` } // a superset of success/error responses. if 'error' is not empty, only error fields // will be present, and vice versa type accessTokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` Scope string `json:"scope"` Error string `json:"error"` ErrorDescription string `json:"error_description"` Interval int `json:"interval"` } func NewGitHubOauthClient(oauthClientID string) *Client { if oauthClientID == "" { return nil } return &Client{ BaseURL: "https://github.com", ClientID: oauthClientID, } } // DeviceCodeAuthToken performs the device based oauth flow for obtaining a user access token // returns a non-empty access token string and nil err, or empty string and an error. May // cache or retrieve token from macos keychain, if possible. If cached token is not found, // this function blocks on user input. // See: https://docs.github.com/en/developers/apps/authorizing-oauth-apps#device-flow func (g *Client) DeviceCodeAuthToken() (string, error) { token, found := keychainFindToken() if found { return token, nil } // Step 1: App requests the device and user verification codes from GitHub authResp, err := g.getDeviceCode() if err != nil { return "", err } // Step 2: Prompt the user to enter the user code in a browser fmt.Printf( "Your device code is: %s Enter this code at: %s\n", authResp.UserCode, authResp.VerificationURI, ) // Step 3: App polls GitHub to check if the user authorized the device interval := time.Duration(authResp.Interval) * time.Second tokenResp, err := g.waitForAccessToken(authResp.DeviceCode, interval) if err != nil { return "", err } token = tokenResp.AccessToken if err = keychainAddToken(token); err != nil { fmt.Printf( "warning: failed to store token in local keychain, continuing. error: %v\n", err.Error(), ) } return token, nil } func (g *Client) accessTokenURL() string { return fmt.Sprintf("%s%s", g.BaseURL, accessTokenPath) } func (g *Client) deviceCodeURL() string { return fmt.Sprintf("%s%s", g.BaseURL, deviceCodePath) } func setGitHubJSONHeaders(r *http.Request) { r.Header.Add("Accept", "application/vnd.github.v3+json") r.Header.Add("Content-Type", "application/json") } func (g *Client) getDeviceCode() (*deviceCodeResponse, error) { // create request authReqRaw, err := json.Marshal(&deviceCodeRequest{ClientID: g.ClientID, Scope: "repo delete_repo"}) if err != nil { return nil, err } authReqBody := bytes.NewReader(authReqRaw) authRequest, err := http.NewRequest("POST", g.deviceCodeURL(), authReqBody) if err != nil { return nil, err } setGitHubJSONHeaders(authRequest) // send request authResponse, err := http.DefaultClient.Do(authRequest) if err != nil { return nil, err } // parse response authRespRaw, err := io.ReadAll(authResponse.Body) if err != nil { return nil, err } authResp := &deviceCodeResponse{} err = json.Unmarshal(authRespRaw, authResp) if err != nil { return nil, err } return authResp, nil } func (g *Client) waitForAccessToken(deviceCode string, interval time.Duration) (*accessTokenResponse, error) { // wait a small period, then poll verification uri until user has entered code // but no longer than the expiration in the auth response time.Sleep(interval) tokenReqRaw, err := json.Marshal(&accessTokenRequest{ ClientID: g.ClientID, DeviceCode: deviceCode, GrantType: oathGrantType, }) if err != nil { return nil, err } for { tokenReqBody := bytes.NewReader(tokenReqRaw) tokenRequest, err := http.NewRequest( "POST", g.accessTokenURL(), tokenReqBody, ) if err != nil { return nil, err } setGitHubJSONHeaders(tokenRequest) tokenResRaw, err := http.DefaultClient.Do(tokenRequest) if err != nil { return nil, err } tokenResBody, err := io.ReadAll(tokenResRaw.Body) if err != nil { return nil, err } if err = tokenResRaw.Body.Close(); err != nil { return nil, err } tokenRes := &accessTokenResponse{} if err = json.Unmarshal(tokenResBody, tokenRes); err != nil { return nil, err } if tokenRes.Error == "" { return tokenRes, nil } switch tokenRes.Error { case "authorization_pending": // ok. just wait and try again, but avoid default error behavior case "access_denied": return nil, errors.New("user declined auth request") case "slow_down": interval = time.Duration(tokenRes.Interval) * time.Second default: return nil, errors.New(tokenRes.ErrorDescription) } time.Sleep(interval) } } func keychainAddToken(token string) error { command := "security" args := []string{"add-internet-password", "-a", "edge-infra", "-s", "dev-edge-ncr-oauth", "-w", token} return exec.Command(command, args...).Run() } func keychainFindToken() (string, bool) { command := "security" args := []string{"find-internet-password", "-a", "edge-infra", "-s", "dev-edge-ncr-oauth", "-w"} cmd := exec.Command(command, args...) out, err := cmd.Output() if err != nil { return "", false } token := strings.TrimSpace(string(out)) return token, true }