Skip to content

Commit

Permalink
Initial
Browse files Browse the repository at this point in the history
  • Loading branch information
shivas committed Oct 9, 2021
1 parent be4c1e0 commit 62997c6
Show file tree
Hide file tree
Showing 5 changed files with 402 additions and 0 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## EVESSO

EVE SSO is small library/example of EVE online SSO v2 implementation.

Please check `example` folder on how to use it.
241 changes: 241 additions & 0 deletions evesso.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
package evesso

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"

"github.com/golang-jwt/jwt"
"github.com/lestrrat-go/jwx/jwk"
)

type serverInfo struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
ResponseTypesSupported []string `json:"response_types_supported"`
JwksURI string `json:"jwks_uri"`
RevocationEndpoint string `json:"revocation_endpoint"`
RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported"`
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
}

type ExchangeCodeResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
TokenType string `json:"token_type"`
RefreshToken string `json:"refresh_token"`
}

const eveOAuthServer = "https://login.eveonline.com/.well-known/oauth-authorization-server"

type Client struct {
httpClient *http.Client
discovery serverInfo
keySet jwk.Set
callbackURL string
clientID string
clientSecret string
}

// NewClient constructor
func NewClient(ctx context.Context, httpClient *http.Client, clientID, clientSecret, callbackURL string) (*Client, error) {
if httpClient == nil {
httpClient = http.DefaultClient
}

client := &Client{
httpClient: httpClient,
clientID: clientID,
clientSecret: clientSecret,
callbackURL: callbackURL,
}

req, err := http.NewRequestWithContext(ctx, "GET", eveOAuthServer, nil)
if err != nil {
return nil, err
}

resp, err := client.httpClient.Do(req)
if err != nil {
return nil, err
}

defer resp.Body.Close()

err = json.NewDecoder(resp.Body).Decode(&client.discovery)
if err != nil {
return nil, err
}

return client, nil
}

// AuthenticateURL return authentication URL created with provided state and scopes.
func (c *Client) AuthenticateURL(state string, scopes ...string) string {
u := url.Values{
"response_type": {"code"},
"redirect_uri": {c.callbackURL},
"client_id": {c.clientID},
"scope": {strings.Join(scopes, " ")},
"state": {state},
}

return fmt.Sprintf("%s?%s", c.discovery.AuthorizationEndpoint, u.Encode())
}

// ExchangeCode excanges code for JWT token.
func (c *Client) ExchangeCode(ctx context.Context, code string) (result ExchangeCodeResponse, err error) {
v := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
}

req, err := http.NewRequestWithContext(ctx, "POST", c.discovery.TokenEndpoint, strings.NewReader(v.Encode()))
if err != nil {
return result, err
}

req.Header.Set("Authorization", "Basic "+c.getAuthHeaderValue())
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

resp, err := c.httpClient.Do(req)
if err != nil {
return result, err
}

defer resp.Body.Close()

err = json.NewDecoder(resp.Body).Decode(&result)
if err != nil {
return result, err
}

return result, nil
}

// RevokeToken revokes refresh token.
func (c *Client) RevokeToken(ctx context.Context, refreshToken string) error {
v := url.Values{
"token_type_hint": {"refresh_token"},
"token": {refreshToken},
}

req, err := http.NewRequestWithContext(ctx, "POST", c.discovery.RevocationEndpoint, strings.NewReader(v.Encode()))
if err != nil {
return err
}

req.Header.Set("Authorization", "Basic "+c.getAuthHeaderValue())
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

resp, err := c.httpClient.Do(req)
if err != nil {
return err
}

defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("revocation of token returned not expected status code: %d", resp.StatusCode)
}

return nil
}

// GetCharacterDetails returns characterID and characterName from provided JWT token claims.
func (c *Client) GetCharacterDetails(t *jwt.Token) (characterID int, characterName string, err error) {
claims, ok := t.Claims.(jwt.MapClaims)
if !ok {
return 0, "", errors.New("provided token claims could not be mapped")
}

sub, ok := claims["sub"].(string)
if !ok {
return 0, "", errors.New("sub claim of JWT token is not string")
}

characterName, ok = claims["name"].(string)
if !ok {
return 0, "", errors.New("name claim of JWT token is not string")
}

idParts := strings.SplitN(sub, ":", 3)
if len(idParts) < 3 {
return 0, "", errors.New("sub claim of JWT token doesn't have proper formatting")
}

characterID, err = strconv.Atoi(idParts[2])
if err != nil {
return 0, "", err
}

return characterID, characterName, nil
}

// ParseToken parses and validates token
func (c *Client) ParseToken(ctx context.Context, token string) (*jwt.Token, error) {
parsedToken, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
var err error

if c.keySet == nil {
c.keySet, err = jwk.Fetch(ctx, c.discovery.JwksURI, jwk.WithHTTPClient(c.httpClient))
if err != nil {
return nil, err
}
}

keyID, ok := t.Header["kid"].(string)
if !ok {
return nil, errors.New("expecting JWT header to have string kid")
}

if key, ok := c.keySet.LookupKeyID(keyID); ok {
var z interface{}

err = key.Raw(&z)
if err != nil {
return nil, err
}

return z, nil
}

return "", errors.New("no key for JTW token found")
})

if err != nil {
return nil, err
}

if claims, ok := parsedToken.Claims.(jwt.MapClaims); ok && parsedToken.Valid {
if !claims.VerifyIssuer(c.discovery.Issuer, true) {
return nil, fmt.Errorf("token issuer is not %q", c.discovery.Issuer)
}

partyID, ok := claims["azp"].(string)
if !ok || partyID != c.clientID {
return nil, errors.New("token issued not to our client id")
}
}

return parsedToken, nil
}

// getAuthHeaderValue constructs Basic auth header value
func (c *Client) getAuthHeaderValue() string {
var buf bytes.Buffer

fmt.Fprintf(&buf, "%s:%s", c.clientID, c.clientSecret)

return base64.StdEncoding.EncodeToString(buf.Bytes())
}
83 changes: 83 additions & 0 deletions example/example.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package main

import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"

"github.com/shivas/evesso"
)

func main() {
const state = "mysecretstate"

ctx := context.Background()

type authReply struct {
code string
state string
}

// to run this please create EVE 3rd party app with callback to: http://localhost:8080 (any path), and set environment variables used below
client, err := evesso.NewClient(ctx, nil, os.Getenv("EVE_CLIENT_ID"), os.Getenv("EVE_CLIENT_SECRET"), "http://localhost:8080/auth/callback")
if err != nil {
log.Fatal(err)
}

authURL := client.AuthenticateURL(state)
fmt.Printf("Auth Start URL: %s\n", authURL)

authChan := make(chan authReply)

go func() {
errServer := http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query()["code"]
state := r.URL.Query()["state"]

select {
case authChan <- authReply{code: code[0], state: state[0]}:

default:
}
}))
if errServer != nil {
log.Fatal(err)
}
}()

codeState := <-authChan
fmt.Printf("%#v\n", codeState)

if codeState.state != state {
log.Fatal(errors.New("received state back missmatched"))
}

r, err := client.ExchangeCode(ctx, codeState.code)
if err != nil {
log.Fatal(err)
}

fmt.Printf("accesstoken:\n%s\n", r.AccessToken)

decodedToken, err := client.ParseToken(ctx, r.AccessToken)
if err != nil {
log.Fatal(err)
}

fmt.Printf("is token valid: %t\n", decodedToken.Valid)

characterID, characterName, err := client.GetCharacterDetails(decodedToken)
if err != nil {
log.Fatal(err)
}

fmt.Printf("Character: %q with ID: %d logged in.\n", characterName, characterID)

err = client.RevokeToken(ctx, r.RefreshToken)
if err != nil {
log.Fatal(err)
}
}
8 changes: 8 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module github.com/shivas/evesso

go 1.16

require (
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/lestrrat-go/jwx v1.2.7
)
Loading

0 comments on commit 62997c6

Please sign in to comment.