From 691d7abfdf5e8aa057483a1eb4340c71e45253de Mon Sep 17 00:00:00 2001 From: Mike Crute Date: Thu, 7 Sep 2023 11:27:41 -0700 Subject: Support Oauth2 discovery --- app/controllers/oauth2_discovery.go | 27 ++++++++++++++++++++ app/models/oauth2.go | 47 ++++++++++++++++++++++++++++++++++ cmd/client/oauth2.go | 51 ++++++++++++++++++++++++++++++++----- cmd/web/server.go | 15 ++++++++++- 4 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 app/controllers/oauth2_discovery.go diff --git a/app/controllers/oauth2_discovery.go b/app/controllers/oauth2_discovery.go new file mode 100644 index 0000000..15528e6 --- /dev/null +++ b/app/controllers/oauth2_discovery.go @@ -0,0 +1,27 @@ +package controllers + +import ( + "fmt" + "net/http" + + "code.crute.us/mcrute/ssh-proxy/app/models" + "github.com/labstack/echo/v4" +) + +type Oauth2DiscoveryController struct { + Hostname string +} + +func (d *Oauth2DiscoveryController) Handle(c echo.Context) error { + return c.JSON(http.StatusOK, models.OauthDiscoveryMetadata{ + Issuer: d.Hostname, + AuthorizationEndpoint: fmt.Sprintf("%s/auth/login", d.Hostname), // Not really supported here + TokenEndpoint: fmt.Sprintf("%s/auth/token", d.Hostname), + DeviceAuthorizationEndpoint: fmt.Sprintf("%s/auth/device", d.Hostname), + SupportedResponseTypes: []string{models.ResponseTypeCode}, + SupportedGrantTypes: []string{models.GrantTypeDevice}, + SupportedResponseModes: []string{models.ResponseModeQuery}, + SupportedUILocales: []string{"en-us"}, + SupportedChallengeCodeMethods: []string{models.ChallengeTypeSHA256}, + }) +} diff --git a/app/models/oauth2.go b/app/models/oauth2.go index 9bfde0a..65d37d4 100644 --- a/app/models/oauth2.go +++ b/app/models/oauth2.go @@ -101,3 +101,50 @@ func (c *PKCEChallenge) Challenge() string { func (c *PKCEChallenge) EqualString(o string) bool { return subtle.ConstantTimeCompare([]byte(o), []byte(c.Challenge())) != 1 } + +const ( + GrantTypeAuthCode = "authorization_code" // RFC7591 + GrantTypeImplicit = "implicit" // RFC7591 + GrantTypePassword = "password" // RFC7591 + GrantTypeClientCreds = "client_credentials" // RFC7591 + GrantTypeRefreshToken = "refresh_token" // RFC7591 + GrantTypeBearerJwt = "urn:ietf:params:oauth:grant-type:jwt-bearer" // RFC7591 + GrantTypeBearerSaml = "urn:ietf:params:oauth:grant-type:saml2-bearer" // RFC7591 + GrantTypeDevice = "urn:ietf:params:oauth:grant-type:device_code" // RFC8628 + ResponseTypeCode = "code" // RFC7591 + ResponseTypeToken = "token" // RFC7591 + ResponseModeQuery = "query" // RFC7591 + ResponseModeFragment = "fragment" // RFC7591 + ResponseModeFormPost = "form_post" // RFC7591 + ChallengeTypePlain = "plain" // RFC7636 + ChallengeTypeSHA256 = "S256" // RFC7636 + Oauth2MetadataPath = "/.well-known/oauth-authorization-server" + Oauth2MetadataCompatPath = "/.well-known/openid-configuration" +) + +// All options are required unless omitempty +type OauthDiscoveryMetadata struct { + Issuer string `json:"issuer"` // RFC88414, https url w/no query/fragment + AuthorizationEndpoint string `json:"authorization_endpoint"` // RFC88414 + TokenEndpoint string `json:"token_endpoint"` // RFC88414 + SupportedResponseTypes []string `json:"response_types_supported"` // RFC88414 + JWKSUri string `json:"jwks_uri,omitempty"` // RFC88414 + RegistrationEndpoint string `json:"registration_endpoint,omitempty"` // RFC88414 + SupportedScopes []string `json:"scopes_supported,omitempty"` // RFC88414 + SupportedResponseModes []string `json:"response_modes_supported,omitempty"` // RFC88414 + SupportedGrantTypes []string `json:"grant_types_supported,omitempty"` // RFC88414, default: authorization_code, implicit + SupportedAuthMethods []string `json:"token_endpoint_auth_methods_supported,omitempty"` // RFC88414 + SupportedSigningAlgs []string `json:"token_endpoint_auth_signing_alg_values_supported,omitempty"` // RFC88414 + SupportedUILocales []string `json:"ui_locales_supported,omitempty"` // RFC88414, RFC5646 codes + PolicyUri string `json:"op_policy_uri,omitempty"` // RFC88414 + TosUri string `json:"op_tos_uri,omitempty"` // RFC88414 + RevocationEndpoint string `json:"revocation_endpoint,omitempty"` // RFC88414 + SupportedRevocationAuthMethods []string `json:"revocation_endpoint_auth_methods_supported,omitempty"` // RFC88414 + SupportedRevocationSigningAlgs []string `json:"revocation_endpoint_auth_signing_alg_values_supported,omitempty"` // RFC88414 + IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"` // RFC88414 + SupportedIntrospectionAuthMethods []string `json:"introspection_endpoint_auth_methods_supported,omitempty"` // RFC88414 + SupportedIntrospectionSigningAlgs []string `json:"introspection_endpoint_auth_signing_alg_values_supported,omitempty"` // RFC88414 + SupportedChallengeCodeMethods []string `json:"code_challenge_methods_supported,omitempty"` // RFC88414 + ServiceDocumentation string `json:"service_documentation,omitempty"` // RFC88414 + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint,omitempty"` // RFC8628 +} diff --git a/cmd/client/oauth2.go b/cmd/client/oauth2.go index 6667c5a..1ccdaaa 100644 --- a/cmd/client/oauth2.go +++ b/cmd/client/oauth2.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strings" "time" @@ -23,6 +24,36 @@ type Oauth2PKCEDeviceClient struct { interval time.Duration } +func (c *Oauth2PKCEDeviceClient) discoverHost(ctx context.Context) (*models.OauthDiscoveryMetadata, error) { + u := &url.URL{ + Scheme: "https", + Host: c.Host, + Path: models.Oauth2MetadataPath, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Oauth2 discovery request failed with code %d", res.StatusCode) + } + + var resp models.OauthDiscoveryMetadata + if err := json.NewDecoder(res.Body).Decode(&resp); err != nil { + return nil, err + } + + return &resp, nil +} + func (c *Oauth2PKCEDeviceClient) Authorize(ctx context.Context) (*models.DeviceAuthorizationResponse, error) { challenge, err := models.NewPKCEChallenge() if err != nil { @@ -40,8 +71,12 @@ func (c *Oauth2PKCEDeviceClient) Authorize(ctx context.Context) (*models.DeviceA return nil, err } - url := fmt.Sprintf("https://%s/auth/device", c.Host) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(values.Encode())) + md, err := c.discoverHost(ctx) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, md.DeviceAuthorizationEndpoint, strings.NewReader(values.Encode())) if err != nil { return nil, err } @@ -53,7 +88,7 @@ func (c *Oauth2PKCEDeviceClient) Authorize(ctx context.Context) (*models.DeviceA } defer res.Body.Close() - if res.StatusCode != 200 { + if res.StatusCode != http.StatusOK { var resError models.Oauth2Error if err := json.NewDecoder(res.Body).Decode(&resError); err != nil { return nil, err @@ -85,8 +120,12 @@ func (c *Oauth2PKCEDeviceClient) fetchToken(ctx context.Context, deviceCode stri return nil, err } - url := fmt.Sprintf("https://%s/auth/token", c.Host) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(values.Encode())) + md, err := c.discoverHost(ctx) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, md.TokenEndpoint, strings.NewReader(values.Encode())) if err != nil { return nil, err } @@ -98,7 +137,7 @@ func (c *Oauth2PKCEDeviceClient) fetchToken(ctx context.Context, deviceCode stri } defer res.Body.Close() - if res.StatusCode != 200 { + if res.StatusCode != http.StatusOK { var resError models.Oauth2Error if err := json.NewDecoder(res.Body).Decode(&resError); err != nil { return nil, err diff --git a/cmd/web/server.go b/cmd/web/server.go index e930257..4867970 100644 --- a/cmd/web/server.go +++ b/cmd/web/server.go @@ -171,15 +171,25 @@ func webMain(cfg app.Config, embeddedTemplates, embeddedClients fs.FS, appVersio Webauthn: wauthn, } + // TODO: Clean up this hack and expose these to echo + hostname := fmt.Sprintf("https://%s", cfg.Hostnames[0]) + if strings.HasPrefix(cfg.Hostnames[0], "dev.") { + hostname += ":8070" + } + o2dc := &controllers.OAuth2DeviceController[*app.Session]{ Logger: s.Logger, AuthSessions: authSessionStore, OauthClients: oauthClientStore, - Hostname: fmt.Sprintf("https://%s", cfg.Hostnames[0]), // TODO + Hostname: hostname, PollSeconds: cfg.OauthDevicePollSecs, SessionExpiration: cfg.OauthSessionTimeout, } + od := controllers.Oauth2DiscoveryController{ + Hostname: hostname, + } + ph := &controllers.ProxyHandler{ Logger: s.Logger, Users: userStore, @@ -260,5 +270,8 @@ func webMain(cfg app.Config, embeddedTemplates, embeddedClients fs.FS, appVersio pg.GET("/:host/:port", ph.Handle) } + s.GET(models.Oauth2MetadataPath, od.Handle) + s.GET(models.Oauth2MetadataCompatPath, od.Handle) + s.RunForever(!cfg.DisableBackgroundJobs) } -- cgit v1.2.3