Skip to content

feat: add API endpoint for retrieving OIDC logout URL #17015

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

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
feat: support OIDC logout with Coder logout
  • Loading branch information
esifea committed Mar 25, 2025
commit 6bffc22127a09e3b8126c8ce892c87cb4586a42a
1 change: 1 addition & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,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
90 changes: 90 additions & 0 deletions coderd/userauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"net/mail"
"net/url"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -743,6 +744,95 @@ func (api *API) postLogout(rw http.ResponseWriter, r *http.Request) {
})
}

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

// 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
}

// 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 {
api.Logger.Error(ctx, "failed to retrieve OIDC user link", "error", err)
if xerrors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "No OIDC link found for this user.",
})
} else {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to retrieve user authentication data.",
})
}
return
}

rawIDToken := link.OAuthAccessToken

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

if oidcEndpoint == "" {
api.Logger.Error(ctx, "missing OIDC logout endpoint")
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "OIDC configuration is missing.",
})
return
}

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

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

if oidcClientID != "" {
q.Set("client_id", oidcClientID)
}
if rawIDToken != "" {
q.Set("id_token_hint", rawIDToken)
}
if logoutURI != "" {
q.Set("logout_uri", logoutURI)
}

logoutURL.RawQuery = q.Encode()

// Return full logout URL
response := map[string]string{"oidc_logout_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
22 changes: 22 additions & 0 deletions codersdk/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,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 @@ -1966,6 +1968,26 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
Group: &deploymentGroupOIDC,
YAML: "dangerousSkipIssuerChecks",
},
{
Name: "OIDC logout endpoint",
Description: "OIDC endpoint for logout.",
Flag: "logout-endpoint",
Env: "CODER_OIDC_LOGOUT_ENDPOINT",
Default: "",
Value: &c.OIDC.LogoutEndpoint,
Group: &deploymentGroupOIDC,
YAML: "logoutEndpoint",
},
{
Name: "OIDC logout redirect URI",
Description: "OIDC redirect URI after logout.",
Flag: "logout-redirect-uri",
Env: "CODER_OIDC_LOGOUT_URI",
Default: "",
Value: &c.OIDC.LogoutRedirectURI,
Group: &deploymentGroupOIDC,
YAML: "logoutRedirectURI",
},
// Telemetry settings
telemetryEnable,
{
Expand Down
21 changes: 20 additions & 1 deletion site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,26 @@ class ApiMethods {
};

logout = async (): Promise<void> => {
return this.axios.post("/api/v2/users/logout");
try {
// Fetch the stored ID token from the backend
const response = await this.axios.get("/api/v2/users/oidc-logout");

// Redirect to OIDC logout after Coder logout
if (response.data.oidc_logout_url) {
// Coder session logout
await this.axios.post("/api/v2/users/logout");

// OIDC logout
window.location.href = response.data.oidc_logout_url;
} else {
// Redirect normally if no token is available
console.warn("No ID token found, continuing logout without OIDC logout.");
return this.axios.post("/api/v2/users/logout");
}
} catch (error) {
console.error("Logout failed", error);
return this.axios.post("/api/v2/users/logout");
}
};

getAuthenticatedUser = async () => {
Expand Down
2 changes: 2 additions & 0 deletions site/src/api/typesGenerated.ts

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