Skip to content

Commit f795ba8

Browse files
authored
Merge pull request #3 from CryptoLabInc/geun-hyo/coder-ui
Geun hyo/coder UI
2 parents 21f9dd6 + 5853a98 commit f795ba8

File tree

26 files changed

+498
-56
lines changed

26 files changed

+498
-56
lines changed

cli/testdata/coder_server_--help.golden

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,9 @@ OIDC OPTIONS:
586586
--oidc-username-field string, $CODER_OIDC_USERNAME_FIELD (default: preferred_username)
587587
OIDC claim field to use as the username.
588588

589+
--oidc-logout-redirect-uri string, $CODER_OIDC_LOGOUT_URI
590+
OIDC redirect URI after logout.
591+
589592
--oidc-sign-in-text string, $CODER_OIDC_SIGN_IN_TEXT (default: OpenID Connect)
590593
The text to show on the OpenID Connect sign in button.
591594

cli/testdata/server-config.yaml.golden

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,9 @@ oidc:
389389
# an insecure OIDC configuration. It is not recommended to use this flag.
390390
# (default: <unset>, type: bool)
391391
dangerousSkipIssuerChecks: false
392+
# OIDC redirect URI after logout.
393+
# (default: <unset>, type: string)
394+
logoutRedirectURI: ""
392395
# Telemetry is critical to our ability to improve Coder. We strip all personal
393396
# information before sending data to our servers. Please only disable telemetry
394397
# when required by your organization's security policy.

coderd/apidoc/docs.go

Lines changed: 39 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,6 +1106,7 @@ func New(options *Options) *API {
11061106
r.Post("/", api.postUser)
11071107
r.Get("/", api.users)
11081108
r.Post("/logout", api.postLogout)
1109+
r.Get("/oidc-logout", api.userOIDCLogoutURL)
11091110
// These routes query information about site wide roles.
11101111
r.Route("/roles", func(r chi.Router) {
11111112
r.Get("/", api.AssignableSiteRoles)

coderd/userauth.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ package coderd
33
import (
44
"context"
55
"database/sql"
6+
"encoding/base64"
67
"errors"
78
"fmt"
9+
"io"
810
"net/http"
911
"net/mail"
12+
"net/url"
1013
"sort"
1114
"strconv"
1215
"strings"
@@ -734,6 +737,197 @@ func (api *API) postLogout(rw http.ResponseWriter, r *http.Request) {
734737
})
735738
}
736739

740+
// getDiscoveryEndpoints will return endpoints for end session and revocation
741+
func (api *API) getDiscoveryEndpoints() (endSessionEndpoint string, revocationEndpoint string, err error) {
742+
oidcProvider := api.OIDCConfig.Provider
743+
744+
var discoveryConfig struct {
745+
EndSessionEndpoint string `json:"end_session_endpoint"`
746+
RevocationEndpoint string `json:"revocation_endpoint"`
747+
}
748+
749+
// Extract endpoints
750+
if err := oidcProvider.Claims(&discoveryConfig); err != nil {
751+
return "", "", xerrors.Errorf("failed to extract endpoints from OIDC provider discovery claims: %w", err)
752+
}
753+
754+
return discoveryConfig.EndSessionEndpoint, discoveryConfig.RevocationEndpoint, nil
755+
}
756+
757+
// revokeOAuthToken will revoke a particular token
758+
func (api *API) revokeOAuthToken(ctx context.Context, token string, revocationEndpoint string) error {
759+
logger := api.Logger.Named(userAuthLoggerName)
760+
761+
if token == "" || revocationEndpoint == "" {
762+
logger.Warn(ctx, "skip OAuth token revocation")
763+
return nil
764+
}
765+
766+
dvOIDC := api.DeploymentValues.OIDC
767+
oidcClientID := dvOIDC.ClientID.Value()
768+
oidcClientSecret := dvOIDC.ClientSecret.Value()
769+
770+
if oidcClientID == "" || oidcClientSecret == "" {
771+
return xerrors.New("missing required configs for revocation (endpoint, client ID, or secret)")
772+
}
773+
774+
data := url.Values{}
775+
data.Set("token", token)
776+
777+
revokeReq, err := http.NewRequestWithContext(ctx, http.MethodPost, revocationEndpoint, strings.NewReader(data.Encode()))
778+
if err != nil {
779+
return xerrors.Errorf("failed to create revoke request object: %w", err)
780+
}
781+
782+
revokeReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
783+
auth := base64.StdEncoding.EncodeToString([]byte(oidcClientID + ":" + oidcClientSecret))
784+
revokeReq.Header.Set("Authorization", "Basic "+auth)
785+
786+
httpClient := &http.Client{}
787+
resp, err := httpClient.Do(revokeReq)
788+
if err != nil {
789+
return xerrors.Errorf("failed to send revoke request to %s: %w", revocationEndpoint, err)
790+
}
791+
defer resp.Body.Close()
792+
793+
if resp.StatusCode != http.StatusOK {
794+
respBodyBytes, _ := io.ReadAll(resp.Body)
795+
respBodyStr := string(respBodyBytes)
796+
797+
logger.Warn(ctx, "failed to request OAuth token revocation",
798+
slog.F("status_code", resp.StatusCode),
799+
slog.F("response_body", respBodyStr),
800+
slog.F("endpoint", revocationEndpoint),
801+
slog.F("client_id", oidcClientID),
802+
)
803+
804+
return xerrors.Errorf("failed to revoke with status %d: %s", resp.StatusCode, respBodyStr)
805+
}
806+
807+
logger.Info(ctx, "success to revoke OAuth token", slog.F("status_code", resp.StatusCode))
808+
return nil // Success
809+
}
810+
811+
// Returns URL for the OIDC logout after token revocation.
812+
//
813+
// @Summary Get user OIDC logout URL
814+
// @ID get-user-oidc-logout-url
815+
// @Security CoderSessionToken
816+
// @Produce json
817+
// @Tags Users
818+
// @Success 200 {object} codersdk.OIDCLogoutResponse "Returns a map containing the OIDC logout URL"
819+
// @Router /users/oidc-logout [get]
820+
func (api *API) userOIDCLogoutURL(rw http.ResponseWriter, r *http.Request) {
821+
logger := api.Logger.Named(userAuthLoggerName)
822+
ctx := r.Context()
823+
824+
// Check if OIDC is configured
825+
if api.OIDCConfig == nil || api.OIDCConfig.Provider == nil {
826+
logger.Warn(ctx, "unable to support OIDC logout with current configuration")
827+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
828+
Message: "Failed to retrieve OIDC configuration.",
829+
})
830+
return
831+
}
832+
833+
// Get logged-in user
834+
apiKey := httpmw.APIKey(r)
835+
user, err := api.Database.GetUserByID(ctx, apiKey.UserID)
836+
if err != nil {
837+
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
838+
Message: "Failed to retrieve user information.",
839+
})
840+
return
841+
}
842+
843+
// Default response: empty URL if OIDC logout is not supported
844+
response := codersdk.OIDCLogoutResponse{URL: ""}
845+
846+
// Retrieve the user's OAuthAccessToken for logout
847+
// nolint:gocritic // We only can get user link by user ID and login type with the system auth.
848+
link, err := api.Database.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx),
849+
database.GetUserLinkByUserIDLoginTypeParams{
850+
UserID: user.ID,
851+
LoginType: user.LoginType,
852+
})
853+
if err != nil {
854+
if xerrors.Is(err, sql.ErrNoRows) {
855+
logger.Warn(ctx, "no OIDC link found for this user")
856+
httpapi.Write(ctx, rw, http.StatusOK, response)
857+
return
858+
}
859+
860+
logger.Error(ctx, "failed to retrieve OIDC user link", "error", err)
861+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
862+
Message: "Failed to retrieve user authentication data.",
863+
Detail: err.Error(),
864+
})
865+
return
866+
}
867+
868+
accessToken := link.OAuthAccessToken
869+
refreshToken := link.OAuthRefreshToken
870+
871+
// Retrieve OIDC environment variables
872+
dvOIDC := api.DeploymentValues.OIDC
873+
oidcClientID := dvOIDC.ClientID.Value()
874+
logoutURI := dvOIDC.LogoutRedirectURI.Value()
875+
876+
endSessionEndpoint, revocationEndpoint, err := api.getDiscoveryEndpoints()
877+
if err != nil {
878+
logger.Error(ctx, "failed to get OIDC discovery endpoints", slog.Error(err))
879+
880+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
881+
Message: "Failed to process OIDC configuration.",
882+
})
883+
return
884+
}
885+
886+
// Perform token revocation first
887+
err = api.revokeOAuthToken(ctx, refreshToken, revocationEndpoint)
888+
if err != nil {
889+
// Do not return since this step is optional
890+
logger.Warn(ctx, "failed to revoke OAuth token during logout", slog.Error(err))
891+
}
892+
893+
if endSessionEndpoint == "" {
894+
logger.Warn(ctx, "missing OIDC logout endpoint")
895+
httpapi.Write(ctx, rw, http.StatusOK, response)
896+
return
897+
}
898+
899+
// Construct OIDC Logout URL
900+
logoutURL, err := url.Parse(endSessionEndpoint)
901+
if err != nil {
902+
logger.Error(ctx, "failed to parse OIDC endpoint", "error", err)
903+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
904+
Message: "Invalid OIDC endpoint.",
905+
Detail: err.Error(),
906+
})
907+
return
908+
}
909+
910+
// Build parameters
911+
q := url.Values{}
912+
913+
if accessToken != "" {
914+
q.Set("id_token_hint", accessToken)
915+
}
916+
if oidcClientID != "" {
917+
q.Set("client_id", oidcClientID)
918+
}
919+
// post_logout_redirect_uri?
920+
if logoutURI != "" {
921+
q.Set("logout_uri", logoutURI)
922+
}
923+
924+
logoutURL.RawQuery = q.Encode()
925+
926+
// Return full logout URL
927+
response.URL = logoutURL.String()
928+
httpapi.Write(ctx, rw, http.StatusOK, response)
929+
}
930+
737931
// GithubOAuth2Team represents a team scoped to an organization.
738932
type GithubOAuth2Team struct {
739933
Organization string

codersdk/deployment.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,8 @@ type OIDCConfig struct {
543543
IconURL serpent.URL `json:"icon_url" typescript:",notnull"`
544544
SignupsDisabledText serpent.String `json:"signups_disabled_text" typescript:",notnull"`
545545
SkipIssuerChecks serpent.Bool `json:"skip_issuer_checks" typescript:",notnull"`
546+
LogoutEndpoint serpent.String `json:"logout_endpoint" typescript:",notnull"`
547+
LogoutRedirectURI serpent.String `json:"logout_redirect_uri" typescript:",notnull"`
546548
}
547549

548550
type TelemetryConfig struct {
@@ -1917,6 +1919,16 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
19171919
Group: &deploymentGroupOIDC,
19181920
YAML: "dangerousSkipIssuerChecks",
19191921
},
1922+
{
1923+
Name: "OIDC logout redirect URI",
1924+
Description: "OIDC redirect URI after logout.",
1925+
Flag: "oidc-logout-redirect-uri",
1926+
Env: "CODER_OIDC_LOGOUT_URI",
1927+
Default: "",
1928+
Value: &c.OIDC.LogoutRedirectURI,
1929+
Group: &deploymentGroupOIDC,
1930+
YAML: "logoutRedirectURI",
1931+
},
19201932
// Telemetry settings
19211933
telemetryEnable,
19221934
{

codersdk/users.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,11 @@ type UserParameter struct {
300300
Value string `json:"value"`
301301
}
302302

303+
// OIDCLogoutResponse represents the response for an OIDC logout request
304+
type OIDCLogoutResponse struct {
305+
URL string `json:"oidc_logout_url"`
306+
}
307+
303308
// UserAutofillParameters returns all recently used parameters for the given user.
304309
func (c *Client) UserAutofillParameters(ctx context.Context, user string, templateID uuid.UUID) ([]UserParameter, error) {
305310
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/autofill-parameters?template_id=%s", user, templateID), nil)

0 commit comments

Comments
 (0)