diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbab65a --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/evesso.go b/evesso.go new file mode 100644 index 0000000..f42905f --- /dev/null +++ b/evesso.go @@ -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()) +} diff --git a/example/example.go b/example/example.go new file mode 100644 index 0000000..a554bc4 --- /dev/null +++ b/example/example.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2c581d1 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1dc7c22 --- /dev/null +++ b/go.sum @@ -0,0 +1,65 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d h1:1iy2qD6JEhHKKhUOA9IWs7mjco7lnw2qx8FsRI2wirE= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE= +github.com/goccy/go-json v0.7.8 h1:CvMH7LotYymYuLGEohBM1lTZWX4g6jzWUUl2aLFuBoE= +github.com/goccy/go-json v0.7.8/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A= +github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= +github.com/lestrrat-go/blackmagic v1.0.0 h1:XzdxDbuQTz0RZZEmdU7cnQxUtFUzgCSPq8RCz4BxIi4= +github.com/lestrrat-go/blackmagic v1.0.0/go.mod h1:TNgH//0vYSs8VXDCfkZLgIrVTTXQELZffUV0tz3MtdQ= +github.com/lestrrat-go/codegen v1.0.2/go.mod h1:JhJw6OQAuPEfVKUCLItpaVLumDGWQznd1VaXrBk9TdM= +github.com/lestrrat-go/httpcc v1.0.0 h1:FszVC6cKfDvBKcJv646+lkh4GydQg2Z29scgUfkOpYc= +github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++0Gf8MBnAvE= +github.com/lestrrat-go/iter v1.0.1 h1:q8faalr2dY6o8bV45uwrxq12bRa1ezKrB6oM9FUgN4A= +github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc= +github.com/lestrrat-go/jwx v1.2.7 h1:wO7fEc3PW56wpQBMU5CyRkrk4DVsXxCoJg7oIm5HHE4= +github.com/lestrrat-go/jwx v1.2.7/go.mod h1:bw24IXWbavc0R2RsOtpXL7RtMyP589yZ1+L7kd09ZGA= +github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4= +github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201217014255-9d1352758620 h1:3wPMTskHO3+O6jqTEXyFcsnuxMQOqYSaHsDxcbUXpqA= +golang.org/x/crypto v0.0.0-20201217014255-9d1352758620/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200918232735-d647fc253266/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= +golang.org/x/tools v0.0.0-20210114065538-d78b04bdf963/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=