@@ -3,10 +3,13 @@ package coderd
3
3
import (
4
4
"context"
5
5
"database/sql"
6
+ "encoding/base64"
6
7
"errors"
7
8
"fmt"
9
+ "io"
8
10
"net/http"
9
11
"net/mail"
12
+ "net/url"
10
13
"sort"
11
14
"strconv"
12
15
"strings"
@@ -734,6 +737,197 @@ func (api *API) postLogout(rw http.ResponseWriter, r *http.Request) {
734
737
})
735
738
}
736
739
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
+
737
931
// GithubOAuth2Team represents a team scoped to an organization.
738
932
type GithubOAuth2Team struct {
739
933
Organization string
0 commit comments