Skip to content

Commit 7335341

Browse files
committed
feat: support OIDC logout with Coder logout
1 parent 21f9dd6 commit 7335341

File tree

5 files changed

+135
-1
lines changed

5 files changed

+135
-1
lines changed

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: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"net/http"
99
"net/mail"
10+
"net/url"
1011
"sort"
1112
"strconv"
1213
"strings"
@@ -734,6 +735,95 @@ func (api *API) postLogout(rw http.ResponseWriter, r *http.Request) {
734735
})
735736
}
736737

738+
// Returns the OIDC logout URL.
739+
//
740+
// @Summary Returns URL for the OIDC logout
741+
// @ID user-oidc-logout
742+
// @Security CoderSessionToken
743+
// @Produce json
744+
// @Tags Users
745+
// @Success 200 {object} map[string]string "Returns a map containing the OIDC logout URL"
746+
// @Router /users/oidc-logout [get]
747+
func (api *API) userOIDCLogoutURL(rw http.ResponseWriter, r *http.Request) {
748+
ctx := r.Context()
749+
750+
// Get logged-in user
751+
apiKey := httpmw.APIKey(r)
752+
user, err := api.Database.GetUserByID(ctx, apiKey.UserID)
753+
if err != nil {
754+
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
755+
Message: "Failed to retrieve user information.",
756+
})
757+
return
758+
}
759+
760+
// Retrieve the user's OAuthAccessToken for logout
761+
// nolint:gocritic // We only can get user link by user ID and login type with the system auth.
762+
link, err := api.Database.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx),
763+
database.GetUserLinkByUserIDLoginTypeParams{
764+
UserID: user.ID,
765+
LoginType: user.LoginType,
766+
})
767+
if err != nil {
768+
api.Logger.Error(ctx, "failed to retrieve OIDC user link", "error", err)
769+
if xerrors.Is(err, sql.ErrNoRows) {
770+
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
771+
Message: "No OIDC link found for this user.",
772+
})
773+
} else {
774+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
775+
Message: "Failed to retrieve user authentication data.",
776+
})
777+
}
778+
return
779+
}
780+
781+
rawIDToken := link.OAuthAccessToken
782+
783+
// Retrieve OIDC environment variables
784+
dvOIDC := api.DeploymentValues.OIDC
785+
oidcEndpoint := dvOIDC.LogoutEndpoint.Value()
786+
oidcClientID := dvOIDC.ClientID.Value()
787+
logoutURI := dvOIDC.LogoutRedirectURI.Value()
788+
789+
if oidcEndpoint == "" {
790+
api.Logger.Error(ctx, "missing OIDC logout endpoint")
791+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
792+
Message: "OIDC configuration is missing.",
793+
})
794+
return
795+
}
796+
797+
// Construct OIDC Logout URL
798+
logoutURL, err := url.Parse(oidcEndpoint)
799+
if err != nil {
800+
api.Logger.Error(ctx, "failed to parse OIDC endpoint", "error", err)
801+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
802+
Message: "Invalid OIDC endpoint.",
803+
})
804+
return
805+
}
806+
807+
// Build parameters
808+
q := url.Values{}
809+
810+
if oidcClientID != "" {
811+
q.Set("client_id", oidcClientID)
812+
}
813+
if rawIDToken != "" {
814+
q.Set("id_token_hint", rawIDToken)
815+
}
816+
if logoutURI != "" {
817+
q.Set("logout_uri", logoutURI)
818+
}
819+
820+
logoutURL.RawQuery = q.Encode()
821+
822+
// Return full logout URL
823+
response := map[string]string{"oidc_logout_url": logoutURL.String()}
824+
httpapi.Write(ctx, rw, http.StatusOK, response)
825+
}
826+
737827
// GithubOAuth2Team represents a team scoped to an organization.
738828
type GithubOAuth2Team struct {
739829
Organization string

codersdk/deployment.go

Lines changed: 22 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,26 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
19171919
Group: &deploymentGroupOIDC,
19181920
YAML: "dangerousSkipIssuerChecks",
19191921
},
1922+
{
1923+
Name: "OIDC logout endpoint",
1924+
Description: "OIDC endpoint for logout.",
1925+
Flag: "logout-endpoint",
1926+
Env: "CODER_OIDC_LOGOUT_ENDPOINT",
1927+
Default: "",
1928+
Value: &c.OIDC.LogoutEndpoint,
1929+
Group: &deploymentGroupOIDC,
1930+
YAML: "logoutEndpoint",
1931+
},
1932+
{
1933+
Name: "OIDC logout redirect URI",
1934+
Description: "OIDC redirect URI after logout.",
1935+
Flag: "logout-redirect-uri",
1936+
Env: "CODER_OIDC_LOGOUT_URI",
1937+
Default: "",
1938+
Value: &c.OIDC.LogoutRedirectURI,
1939+
Group: &deploymentGroupOIDC,
1940+
YAML: "logoutRedirectURI",
1941+
},
19201942
// Telemetry settings
19211943
telemetryEnable,
19221944
{

site/src/api/api.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,26 @@ class ApiMethods {
430430
};
431431

432432
logout = async (): Promise<void> => {
433-
return this.axios.post("/api/v2/users/logout");
433+
try {
434+
// Fetch the stored ID token from the backend
435+
const response = await this.axios.get("/api/v2/users/oidc-logout");
436+
437+
// Redirect to OIDC logout after Coder logout
438+
if (response.data.oidc_logout_url) {
439+
// Coder session logout
440+
await this.axios.post("/api/v2/users/logout");
441+
442+
// OIDC logout
443+
window.location.href = response.data.oidc_logout_url;
444+
} else {
445+
// Redirect normally if no token is available
446+
console.warn("No ID token found, continuing logout without OIDC logout.");
447+
return this.axios.post("/api/v2/users/logout");
448+
}
449+
} catch (error) {
450+
console.error("Logout failed", error);
451+
return this.axios.post("/api/v2/users/logout");
452+
}
434453
};
435454

436455
getAuthenticatedUser = async () => {

site/src/api/typesGenerated.ts

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

0 commit comments

Comments
 (0)