Skip to content

Geun hyo/coder UI #3

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Apr 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cli/testdata/coder_server_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,9 @@ OIDC OPTIONS:
--oidc-username-field string, $CODER_OIDC_USERNAME_FIELD (default: preferred_username)
OIDC claim field to use as the username.

--oidc-logout-redirect-uri string, $CODER_OIDC_LOGOUT_URI
OIDC redirect URI after logout.

--oidc-sign-in-text string, $CODER_OIDC_SIGN_IN_TEXT (default: OpenID Connect)
The text to show on the OpenID Connect sign in button.

Expand Down
3 changes: 3 additions & 0 deletions cli/testdata/server-config.yaml.golden
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,9 @@ oidc:
# an insecure OIDC configuration. It is not recommended to use this flag.
# (default: <unset>, type: bool)
dangerousSkipIssuerChecks: false
# OIDC redirect URI after logout.
# (default: <unset>, type: string)
logoutRedirectURI: ""
# Telemetry is critical to our ability to improve Coder. We strip all personal
# information before sending data to our servers. Please only disable telemetry
# when required by your organization's security policy.
Expand Down
39 changes: 39 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -1106,6 +1106,7 @@ func New(options *Options) *API {
r.Post("/", api.postUser)
r.Get("/", api.users)
r.Post("/logout", api.postLogout)
r.Get("/oidc-logout", api.userOIDCLogoutURL)
// These routes query information about site wide roles.
r.Route("/roles", func(r chi.Router) {
r.Get("/", api.AssignableSiteRoles)
Expand Down
194 changes: 194 additions & 0 deletions coderd/userauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ package coderd
import (
"context"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/mail"
"net/url"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -734,6 +737,197 @@ func (api *API) postLogout(rw http.ResponseWriter, r *http.Request) {
})
}

// getDiscoveryEndpoints will return endpoints for end session and revocation
func (api *API) getDiscoveryEndpoints() (endSessionEndpoint string, revocationEndpoint string, err error) {
oidcProvider := api.OIDCConfig.Provider

var discoveryConfig struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
RevocationEndpoint string `json:"revocation_endpoint"`
}

// Extract endpoints
if err := oidcProvider.Claims(&discoveryConfig); err != nil {
return "", "", xerrors.Errorf("failed to extract endpoints from OIDC provider discovery claims: %w", err)
}

return discoveryConfig.EndSessionEndpoint, discoveryConfig.RevocationEndpoint, nil
}

// revokeOAuthToken will revoke a particular token
func (api *API) revokeOAuthToken(ctx context.Context, token string, revocationEndpoint string) error {
logger := api.Logger.Named(userAuthLoggerName)

if token == "" || revocationEndpoint == "" {
logger.Warn(ctx, "skip OAuth token revocation")
return nil
}

dvOIDC := api.DeploymentValues.OIDC
oidcClientID := dvOIDC.ClientID.Value()
oidcClientSecret := dvOIDC.ClientSecret.Value()

if oidcClientID == "" || oidcClientSecret == "" {
return xerrors.New("missing required configs for revocation (endpoint, client ID, or secret)")
}

data := url.Values{}
data.Set("token", token)

revokeReq, err := http.NewRequestWithContext(ctx, http.MethodPost, revocationEndpoint, strings.NewReader(data.Encode()))
if err != nil {
return xerrors.Errorf("failed to create revoke request object: %w", err)
}

revokeReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
auth := base64.StdEncoding.EncodeToString([]byte(oidcClientID + ":" + oidcClientSecret))
revokeReq.Header.Set("Authorization", "Basic "+auth)

httpClient := &http.Client{}
resp, err := httpClient.Do(revokeReq)
if err != nil {
return xerrors.Errorf("failed to send revoke request to %s: %w", revocationEndpoint, err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
respBodyBytes, _ := io.ReadAll(resp.Body)
respBodyStr := string(respBodyBytes)

logger.Warn(ctx, "failed to request OAuth token revocation",
slog.F("status_code", resp.StatusCode),
slog.F("response_body", respBodyStr),
slog.F("endpoint", revocationEndpoint),
slog.F("client_id", oidcClientID),
)

return xerrors.Errorf("failed to revoke with status %d: %s", resp.StatusCode, respBodyStr)
}

logger.Info(ctx, "success to revoke OAuth token", slog.F("status_code", resp.StatusCode))
return nil // Success
}

// Returns URL for the OIDC logout after token revocation.
//
// @Summary Get user OIDC logout URL
// @ID get-user-oidc-logout-url
// @Security CoderSessionToken
// @Produce json
// @Tags Users
// @Success 200 {object} codersdk.OIDCLogoutResponse "Returns a map containing the OIDC logout URL"
// @Router /users/oidc-logout [get]
func (api *API) userOIDCLogoutURL(rw http.ResponseWriter, r *http.Request) {
logger := api.Logger.Named(userAuthLoggerName)
ctx := r.Context()

// Check if OIDC is configured
if api.OIDCConfig == nil || api.OIDCConfig.Provider == nil {
logger.Warn(ctx, "unable to support OIDC logout with current configuration")
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to retrieve OIDC configuration.",
})
return
}

// Get logged-in user
apiKey := httpmw.APIKey(r)
user, err := api.Database.GetUserByID(ctx, apiKey.UserID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
Message: "Failed to retrieve user information.",
})
return
}

// Default response: empty URL if OIDC logout is not supported
response := codersdk.OIDCLogoutResponse{URL: ""}

// Retrieve the user's OAuthAccessToken for logout
// nolint:gocritic // We only can get user link by user ID and login type with the system auth.
link, err := api.Database.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx),
database.GetUserLinkByUserIDLoginTypeParams{
UserID: user.ID,
LoginType: user.LoginType,
})
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
logger.Warn(ctx, "no OIDC link found for this user")
httpapi.Write(ctx, rw, http.StatusOK, response)
return
}

logger.Error(ctx, "failed to retrieve OIDC user link", "error", err)
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to retrieve user authentication data.",
Detail: err.Error(),
})
return
}

accessToken := link.OAuthAccessToken
refreshToken := link.OAuthRefreshToken

// Retrieve OIDC environment variables
dvOIDC := api.DeploymentValues.OIDC
oidcClientID := dvOIDC.ClientID.Value()
logoutURI := dvOIDC.LogoutRedirectURI.Value()

endSessionEndpoint, revocationEndpoint, err := api.getDiscoveryEndpoints()
if err != nil {
logger.Error(ctx, "failed to get OIDC discovery endpoints", slog.Error(err))

httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to process OIDC configuration.",
})
return
}

// Perform token revocation first
err = api.revokeOAuthToken(ctx, refreshToken, revocationEndpoint)
if err != nil {
// Do not return since this step is optional
logger.Warn(ctx, "failed to revoke OAuth token during logout", slog.Error(err))
}

if endSessionEndpoint == "" {
logger.Warn(ctx, "missing OIDC logout endpoint")
httpapi.Write(ctx, rw, http.StatusOK, response)
return
}

// Construct OIDC Logout URL
logoutURL, err := url.Parse(endSessionEndpoint)
if err != nil {
logger.Error(ctx, "failed to parse OIDC endpoint", "error", err)
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Invalid OIDC endpoint.",
Detail: err.Error(),
})
return
}

// Build parameters
q := url.Values{}

if accessToken != "" {
q.Set("id_token_hint", accessToken)
}
if oidcClientID != "" {
q.Set("client_id", oidcClientID)
}
// post_logout_redirect_uri?
if logoutURI != "" {
q.Set("logout_uri", logoutURI)
}

logoutURL.RawQuery = q.Encode()

// Return full logout URL
response.URL = logoutURL.String()
httpapi.Write(ctx, rw, http.StatusOK, response)
}

// GithubOAuth2Team represents a team scoped to an organization.
type GithubOAuth2Team struct {
Organization string
Expand Down
12 changes: 12 additions & 0 deletions codersdk/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,8 @@ type OIDCConfig struct {
IconURL serpent.URL `json:"icon_url" typescript:",notnull"`
SignupsDisabledText serpent.String `json:"signups_disabled_text" typescript:",notnull"`
SkipIssuerChecks serpent.Bool `json:"skip_issuer_checks" typescript:",notnull"`
LogoutEndpoint serpent.String `json:"logout_endpoint" typescript:",notnull"`
LogoutRedirectURI serpent.String `json:"logout_redirect_uri" typescript:",notnull"`
}

type TelemetryConfig struct {
Expand Down Expand Up @@ -1917,6 +1919,16 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
Group: &deploymentGroupOIDC,
YAML: "dangerousSkipIssuerChecks",
},
{
Name: "OIDC logout redirect URI",
Description: "OIDC redirect URI after logout.",
Flag: "oidc-logout-redirect-uri",
Env: "CODER_OIDC_LOGOUT_URI",
Default: "",
Value: &c.OIDC.LogoutRedirectURI,
Group: &deploymentGroupOIDC,
YAML: "logoutRedirectURI",
},
// Telemetry settings
telemetryEnable,
{
Expand Down
5 changes: 5 additions & 0 deletions codersdk/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,11 @@ type UserParameter struct {
Value string `json:"value"`
}

// OIDCLogoutResponse represents the response for an OIDC logout request
type OIDCLogoutResponse struct {
URL string `json:"oidc_logout_url"`
}

// UserAutofillParameters returns all recently used parameters for the given user.
func (c *Client) UserAutofillParameters(ctx context.Context, user string, templateID uuid.UUID) ([]UserParameter, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/autofill-parameters?template_id=%s", user, templateID), nil)
Expand Down
Loading