diff --git a/egs.go b/egs.go index 14bb279..dd2374a 100644 --- a/egs.go +++ b/egs.go @@ -16,8 +16,9 @@ const ( egsClientID = "34a02cf8f4414e29b15921876da36f9a" egsClientSecret = "daafbccc737745039dffe53d94fc76cf" egsOAuthURL = "account-public-service-prod03.ol.epicgames.com" - eosAuthHeader = "eHl6YTc4OTFwNUQ3czlSNkdtNm1vVEhXR2xvZXJwN0I6S25oMThkdTROVmxGcyszdVErWlBwRENWdG8wV1lmNHlYUDgrT2N3VnQxbw==" eosDeploymentID = "da32ae9c12ae40e8a112c52e1f17f3ba" // Rocket League + eosClientID = "xyza7891p5D7s9R6Gm6moTHWGloerp7B" + eosSecret = "Knh18du4NVlFs+3uQ+ZPpDCVto0WYf4yXP8+OcwVt1o" ) type TokenResponse struct { @@ -55,6 +56,14 @@ type EOSTokenResponse struct { AuthTime string `json:"auth_time"` } +type DeviceAuthResponse struct { + UserCode string `json:"user_code"` + DeviceCode string `json:"device_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + // EGS provides an authentication layer for Epic Games Store -- largely adapted from https://github.com/derrod/legendary type EGS struct { client *http.Client @@ -212,7 +221,7 @@ func (e *EGS) requestEOSToken(params map[string]string) (*EOSTokenResponse, erro } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Authorization", "Basic "+eosAuthHeader) + req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(eosClientID+":"+eosSecret))) req.Header.Set("User-Agent", egsUserAgent) resp, err := e.client.Do(req) @@ -263,3 +272,55 @@ func (e *EGS) RevokeEOSToken(accessToken string) error { return nil } + +// AuthenticateWithDevice initiates the OAuth 2.0 Device Authorization Grant ([RFC 8628]). +// Display VerificationURI and UserCode to the user, then call [EGS.WaitForDeviceAuthorization] to exchange for an EOS token. +// +// [RFC 8628]: https://datatracker.ietf.org/doc/html/rfc8628 +func (e *EGS) AuthenticateWithDevice() (*DeviceAuthResponse, error) { + req, err := http.NewRequest("POST", "https://api.epicgames.dev/epic/oauth/v2/deviceAuthorization", strings.NewReader("client_id="+eosClientID)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", egsUserAgent) + + resp, err := e.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code %s: %s", resp.Status, string(body)) + } + + var deviceAuthResp DeviceAuthResponse + if err := json.Unmarshal(body, &deviceAuthResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &deviceAuthResp, nil +} + +// WaitForDeviceAuthorization polls EOS until the user completes authorization at VerificationURI, then returns an EOS token. +func (e *EGS) WaitForDeviceAuthorization(device *DeviceAuthResponse) (*EOSTokenResponse, error) { + for range device.ExpiresIn / device.Interval { + token, err := e.requestEOSToken(map[string]string{ + "grant_type": "device_code", + "device_code": device.DeviceCode, + }) + if err == nil { + return token, nil + } + time.Sleep(time.Duration(device.Interval) * time.Second) + } + + return nil, fmt.Errorf("device authorization timed out") +} diff --git a/examples/authdevice/main.go b/examples/authdevice/main.go new file mode 100644 index 0000000..fe84a1b --- /dev/null +++ b/examples/authdevice/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "fmt" + "log" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/dank/rlapi" +) + +// Device authorization flow for EGS. For durability save the refresh token in a persistent store, and restart with egs.AuthenticateWithRefreshToken. +// See examples/setup/setup.go for an example on persistent recovery. +func main() { + slog.SetLogLoggerLevel(slog.LevelDebug) + + egs := rlapi.NewEGS() + + device, err := egs.AuthenticateWithDevice() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Visit %s and enter code: %s\n", device.VerificationURI, device.UserCode) + + token, err := egs.WaitForDeviceAuthorization(device) + if err != nil { + log.Fatal(err) + } + + psyNet := rlapi.NewPsyNet() + rpc, err := psyNet.AuthPlayer(token.AccessToken, token.AccountID, "") + if err != nil { + log.Fatal(err) + } + defer rpc.Close() + + // ... do stuff with rpc + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + <-sigChan +}