From 383bf44391e8d03e686f48ac299d07a68b82828c Mon Sep 17 00:00:00 2001 From: DacongDA Date: Wed, 30 Apr 2025 23:42:26 +0800 Subject: [PATCH] feat: support OIDC device flow: "/api/device-auth" (#3757) --- authz/authz.go | 1 + controllers/account.go | 1 + controllers/auth.go | 113 +++++++++++++++++++++++++++++++++ controllers/token.go | 42 +++++++++++- form/auth.go | 1 + object/oidc_discovery.go | 13 ++++ object/token_oauth.go | 21 ++++++ routers/router.go | 1 + web/src/ApplicationEditPage.js | 1 + web/src/EntryPage.js | 1 + web/src/auth/AuthBackend.js | 9 ++- web/src/auth/LoginPage.js | 52 ++++++++++++++- 12 files changed, 252 insertions(+), 4 deletions(-) diff --git a/authz/authz.go b/authz/authz.go index 6b444275..24bc11f7 100644 --- a/authz/authz.go +++ b/authz/authz.go @@ -47,6 +47,7 @@ p, *, *, GET, /api/get-app-login, *, * p, *, *, POST, /api/logout, *, * p, *, *, GET, /api/logout, *, * p, *, *, POST, /api/callback, *, * +p, *, *, POST, /api/device-auth, *, * p, *, *, GET, /api/get-account, *, * p, *, *, GET, /api/userinfo, *, * p, *, *, GET, /api/user, *, * diff --git a/controllers/account.go b/controllers/account.go index af63aecb..3e6a29a9 100644 --- a/controllers/account.go +++ b/controllers/account.go @@ -32,6 +32,7 @@ const ( ResponseTypeIdToken = "id_token" ResponseTypeSaml = "saml" ResponseTypeCas = "cas" + ResponseTypeDevice = "device" ) type Response struct { diff --git a/controllers/auth.go b/controllers/auth.go index 5e563166..86297411 100644 --- a/controllers/auth.go +++ b/controllers/auth.go @@ -25,6 +25,7 @@ import ( "regexp" "strconv" "strings" + "time" "github.com/casdoor/casdoor/captcha" "github.com/casdoor/casdoor/conf" @@ -169,6 +170,32 @@ func (c *ApiController) HandleLoggedIn(application *object.Application, user *ob resp.Data2 = user.NeedUpdatePassword } + } else if form.Type == ResponseTypeDevice { + authCache, ok := object.DeviceAuthMap.LoadAndDelete(form.UserCode) + if !ok { + c.ResponseError(c.T("auth:UserCode Expired")) + return + } + + authCacheCast := authCache.(object.DeviceAuthCache) + if authCacheCast.RequestAt.Add(time.Second * 120).Before(time.Now()) { + c.ResponseError(c.T("auth:UserCode Expired")) + return + } + + deviceAuthCacheDeviceCode, ok := object.DeviceAuthMap.Load(authCacheCast.UserName) + if !ok { + c.ResponseError(c.T("auth:DeviceCode Invalid")) + return + } + + deviceAuthCacheDeviceCodeCast := deviceAuthCacheDeviceCode.(object.DeviceAuthCache) + deviceAuthCacheDeviceCodeCast.UserName = user.Name + deviceAuthCacheDeviceCodeCast.UserSignIn = true + + object.DeviceAuthMap.Store(authCacheCast.UserName, deviceAuthCacheDeviceCodeCast) + + resp = &Response{Status: "ok", Msg: "", Data: userId, Data2: user.NeedUpdatePassword} } else if form.Type == ResponseTypeSaml { // saml flow res, redirectUrl, method, err := object.GetSamlResponse(application, user, form.SamlRequest, c.Ctx.Request.Host) if err != nil { @@ -242,6 +269,7 @@ func (c *ApiController) GetApplicationLogin() { state := c.Input().Get("state") id := c.Input().Get("id") loginType := c.Input().Get("type") + userCode := c.Input().Get("userCode") var application *object.Application var msg string @@ -268,6 +296,19 @@ func (c *ApiController) GetApplicationLogin() { c.ResponseError(err.Error()) return } + } else if loginType == "device" { + deviceAuthCache, ok := object.DeviceAuthMap.Load(userCode) + if !ok { + c.ResponseError(c.T("auth:UserCode Invalid")) + return + } + + deviceAuthCacheCast := deviceAuthCache.(object.DeviceAuthCache) + application, err = object.GetApplication(deviceAuthCacheCast.ApplicationId) + if err != nil { + c.ResponseError(err.Error()) + return + } } clientIp := util.GetClientIpFromRequest(c.Ctx.Request) @@ -1215,3 +1256,75 @@ func (c *ApiController) Callback() { frontendCallbackUrl := fmt.Sprintf("/callback?code=%s&state=%s", code, state) c.Ctx.Redirect(http.StatusFound, frontendCallbackUrl) } + +// DeviceAuth +// @Title DeviceAuth +// @Tag Device Authorization Endpoint +// @Description Endpoint for the device authorization flow +// @router /device-auth [post] +// @Success 200 {object} object.DeviceAuthResponse The Response object +func (c *ApiController) DeviceAuth() { + clientId := c.Input().Get("client_id") + scope := c.Input().Get("scope") + application, err := object.GetApplicationByClientId(clientId) + if err != nil { + c.Data["json"] = object.TokenError{ + Error: err.Error(), + ErrorDescription: err.Error(), + } + c.ServeJSON() + return + } + + if application == nil { + c.Data["json"] = object.TokenError{ + Error: c.T("token:Invalid client_id"), + ErrorDescription: c.T("token:Invalid client_id"), + } + c.ServeJSON() + return + } + + deviceCode := util.GenerateId() + userCode := util.GetRandomName() + + generateTime := 0 + for { + if generateTime > 5 { + c.Data["json"] = object.TokenError{ + Error: "userCode gen", + ErrorDescription: c.T("token:Invalid client_id"), + } + c.ServeJSON() + return + } + _, ok := object.DeviceAuthMap.Load(userCode) + if !ok { + break + } + + generateTime++ + } + + deviceAuthCache := object.DeviceAuthCache{ + UserSignIn: false, + UserName: "", + Scope: scope, + ApplicationId: application.GetId(), + RequestAt: time.Now(), + } + + userAuthCache := object.DeviceAuthCache{ + UserSignIn: false, + UserName: deviceCode, + Scope: scope, + ApplicationId: application.GetId(), + RequestAt: time.Now(), + } + + object.DeviceAuthMap.Store(deviceCode, deviceAuthCache) + object.DeviceAuthMap.Store(userCode, userAuthCache) + + c.Data["json"] = object.GetDeviceAuthResponse(deviceCode, userCode, c.Ctx.Request.Host) + c.ServeJSON() +} diff --git a/controllers/token.go b/controllers/token.go index d007ade2..3b90f802 100644 --- a/controllers/token.go +++ b/controllers/token.go @@ -16,6 +16,7 @@ package controllers import ( "encoding/json" + "time" "github.com/beego/beego/utils/pagination" "github.com/casdoor/casdoor/object" @@ -170,12 +171,17 @@ func (c *ApiController) GetOAuthToken() { tag := c.Input().Get("tag") avatar := c.Input().Get("avatar") refreshToken := c.Input().Get("refresh_token") + deviceCode := c.Input().Get("device_code") if clientId == "" && clientSecret == "" { clientId, clientSecret, _ = c.Ctx.Request.BasicAuth() } - if len(c.Ctx.Input.RequestBody) != 0 { + if grantType == "urn:ietf:params:oauth:grant-type:device_code" { + clientId, clientSecret, _ = c.Ctx.Request.BasicAuth() + } + + if len(c.Ctx.Input.RequestBody) != 0 && grantType != "urn:ietf:params:oauth:grant-type:device_code" { // If clientId is empty, try to read data from RequestBody var tokenRequest TokenRequest err := json.Unmarshal(c.Ctx.Input.RequestBody, &tokenRequest) @@ -219,6 +225,40 @@ func (c *ApiController) GetOAuthToken() { } } + if deviceCode != "" { + deviceAuthCache, ok := object.DeviceAuthMap.Load(deviceCode) + if !ok { + c.Data["json"] = object.TokenError{ + Error: "expired_token", + ErrorDescription: "token is expired", + } + c.ServeJSON() + return + } + + deviceAuthCacheCast := deviceAuthCache.(object.DeviceAuthCache) + if !deviceAuthCacheCast.UserSignIn { + c.Data["json"] = object.TokenError{ + Error: "authorization_pending", + ErrorDescription: "authorization pending", + } + c.ServeJSON() + return + } + + if deviceAuthCacheCast.RequestAt.Add(time.Second * 120).Before(time.Now()) { + c.Data["json"] = object.TokenError{ + Error: "expired_token", + ErrorDescription: "token is expired", + } + c.ServeJSON() + return + } + object.DeviceAuthMap.Delete(deviceCode) + + username = deviceAuthCacheCast.UserName + } + host := c.Ctx.Request.Host token, err := object.GetOAuthToken(grantType, clientId, clientSecret, code, verifier, scope, nonce, username, password, host, refreshToken, tag, avatar, c.GetAcceptLanguage()) if err != nil { diff --git a/form/auth.go b/form/auth.go index 52c2cf17..ef72db43 100644 --- a/form/auth.go +++ b/form/auth.go @@ -70,6 +70,7 @@ type AuthForm struct { FaceId []float64 `json:"faceId"` FaceIdImage []string `json:"faceIdImage"` + UserCode string `json:"userCode"` } func GetAuthFormFieldValue(form *AuthForm, fieldName string) (bool, string) { diff --git a/object/oidc_discovery.go b/object/oidc_discovery.go index 9c21aa10..b4b2b587 100644 --- a/object/oidc_discovery.go +++ b/object/oidc_discovery.go @@ -30,6 +30,7 @@ type OidcDiscovery struct { AuthorizationEndpoint string `json:"authorization_endpoint"` TokenEndpoint string `json:"token_endpoint"` UserinfoEndpoint string `json:"userinfo_endpoint"` + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"` JwksUri string `json:"jwks_uri"` IntrospectionEndpoint string `json:"introspection_endpoint"` ResponseTypesSupported []string `json:"response_types_supported"` @@ -119,6 +120,7 @@ func GetOidcDiscovery(host string) OidcDiscovery { AuthorizationEndpoint: fmt.Sprintf("%s/login/oauth/authorize", originFrontend), TokenEndpoint: fmt.Sprintf("%s/api/login/oauth/access_token", originBackend), UserinfoEndpoint: fmt.Sprintf("%s/api/userinfo", originBackend), + DeviceAuthorizationEndpoint: fmt.Sprintf("%s/api/device-auth", originBackend), JwksUri: fmt.Sprintf("%s/.well-known/jwks", originBackend), IntrospectionEndpoint: fmt.Sprintf("%s/api/login/oauth/introspect", originBackend), ResponseTypesSupported: []string{"code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token", "none"}, @@ -213,3 +215,14 @@ func GetWebFinger(resource string, rels []string, host string) (WebFinger, error return wf, nil } + +func GetDeviceAuthResponse(deviceCode string, userCode string, host string) DeviceAuthResponse { + originFrontend, _ := getOriginFromHost(host) + + return DeviceAuthResponse{ + DeviceCode: deviceCode, + UserCode: userCode, + VerificationUri: fmt.Sprintf("%s/login/oauth/device/%s", originFrontend, userCode), + ExpiresIn: 120, + } +} diff --git a/object/token_oauth.go b/object/token_oauth.go index 8bf81421..babfb90d 100644 --- a/object/token_oauth.go +++ b/object/token_oauth.go @@ -18,6 +18,7 @@ import ( "crypto/sha256" "encoding/base64" "fmt" + "sync" "time" "github.com/casdoor/casdoor/i18n" @@ -37,6 +38,8 @@ const ( EndpointError = "endpoint_error" ) +var DeviceAuthMap = sync.Map{} + type Code struct { Message string `xorm:"varchar(100)" json:"message"` Code string `xorm:"varchar(100)" json:"code"` @@ -71,6 +74,22 @@ type IntrospectionResponse struct { Jti string `json:"jti,omitempty"` } +type DeviceAuthCache struct { + UserSignIn bool + UserName string + ApplicationId string + Scope string + RequestAt time.Time +} + +type DeviceAuthResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationUri string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + func ExpireTokenByAccessToken(accessToken string) (bool, *Application, *Token, error) { token, err := GetTokenByAccessToken(accessToken) if err != nil { @@ -222,6 +241,8 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code token, tokenError, err = GetClientCredentialsToken(application, clientSecret, scope, host) case "token", "id_token": // Implicit Grant token, tokenError, err = GetImplicitToken(application, username, scope, nonce, host) + case "urn:ietf:params:oauth:grant-type:device_code": + token, tokenError, err = GetImplicitToken(application, username, scope, nonce, host) case "refresh_token": refreshToken2, err := RefreshToken(grantType, refreshToken, scope, clientId, clientSecret, host) if err != nil { diff --git a/routers/router.go b/routers/router.go index 7e0c4199..28dc427b 100644 --- a/routers/router.go +++ b/routers/router.go @@ -66,6 +66,7 @@ func initAPI() { beego.Router("/api/get-webhook-event", &controllers.ApiController{}, "GET:GetWebhookEventType") beego.Router("/api/get-captcha-status", &controllers.ApiController{}, "GET:GetCaptchaStatus") beego.Router("/api/callback", &controllers.ApiController{}, "POST:Callback") + beego.Router("/api/device-auth", &controllers.ApiController{}, "POST:DeviceAuth") beego.Router("/api/get-organizations", &controllers.ApiController{}, "GET:GetOrganizations") beego.Router("/api/get-organization", &controllers.ApiController{}, "GET:GetOrganization") diff --git a/web/src/ApplicationEditPage.js b/web/src/ApplicationEditPage.js index af4fd5d0..e2abb3e0 100644 --- a/web/src/ApplicationEditPage.js +++ b/web/src/ApplicationEditPage.js @@ -726,6 +726,7 @@ class ApplicationEditPage extends React.Component { {id: "token", name: "Token"}, {id: "id_token", name: "ID Token"}, {id: "refresh_token", name: "Refresh Token"}, + {id: "urn:ietf:params:oauth:grant-type:device_code", name: "Device Code"}, ].map((item, index) => ) } diff --git a/web/src/EntryPage.js b/web/src/EntryPage.js index da3c3a78..b6f80dd1 100644 --- a/web/src/EntryPage.js +++ b/web/src/EntryPage.js @@ -119,6 +119,7 @@ class EntryPage extends React.Component { this.renderHomeIfLoggedIn()} /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/auth/AuthBackend.js b/web/src/auth/AuthBackend.js index 0c3a4c5e..e10d8fa5 100644 --- a/web/src/auth/AuthBackend.js +++ b/web/src/auth/AuthBackend.js @@ -61,7 +61,14 @@ export function oAuthParamsToQuery(oAuthParams) { } export function getApplicationLogin(params) { - const queryParams = (params?.type === "cas") ? casLoginParamsToQuery(params) : oAuthParamsToQuery(params); + let queryParams = ""; + if (params?.type === "cas") { + queryParams = casLoginParamsToQuery(params); + } else if (params?.type === "device") { + queryParams = `?userCode=${params.userCode}&type=device`; + } else { + queryParams = oAuthParamsToQuery(params); + } return fetch(`${authConfig.serverUrl}/api/get-app-login${queryParams}`, { method: "GET", credentials: "include", diff --git a/web/src/auth/LoginPage.js b/web/src/auth/LoginPage.js index 1f9a7b5c..23f010af 100644 --- a/web/src/auth/LoginPage.js +++ b/web/src/auth/LoginPage.js @@ -65,6 +65,8 @@ class LoginPage extends React.Component { orgChoiceMode: new URLSearchParams(props.location?.search).get("orgChoiceMode") ?? null, userLang: null, loginLoading: false, + userCode: props.userCode ?? (props.match?.params?.userCode ?? null), + userCodeStatus: "", }; if (this.state.type === "cas" && props.match?.params.casApplicationName !== undefined) { @@ -81,7 +83,7 @@ class LoginPage extends React.Component { if (this.getApplicationObj() === undefined) { if (this.state.type === "login" || this.state.type === "saml") { this.getApplication(); - } else if (this.state.type === "code" || this.state.type === "cas") { + } else if (this.state.type === "code" || this.state.type === "cas" || this.state.type === "device") { this.getApplicationLogin(); } else { Setting.showMessage("error", `Unknown authentication type: ${this.state.type}`); @@ -155,13 +157,25 @@ class LoginPage extends React.Component { } getApplicationLogin() { - const loginParams = (this.state.type === "cas") ? Util.getCasLoginParameters("admin", this.state.applicationName) : Util.getOAuthGetParameters(); + let loginParams; + if (this.state.type === "cas") { + loginParams = Util.getCasLoginParameters("admin", this.state.applicationName); + } else if (this.state.type === "device") { + loginParams = {userCode: this.state.userCode, type: this.state.type}; + } else { + loginParams = Util.getOAuthGetParameters(); + } AuthBackend.getApplicationLogin(loginParams) .then((res) => { if (res.status === "ok") { const application = res.data; this.onUpdateApplication(application); } else { + if (this.state.type === "device") { + this.setState({ + userCodeStatus: "expired", + }); + } this.onUpdateApplication(null); this.setState({ msg: res.msg, @@ -266,6 +280,9 @@ class LoginPage extends React.Component { onUpdateApplication(application) { this.props.onUpdateApplication(application); + if (application === null) { + return; + } for (const idx in application.providers) { const provider = application.providers[idx]; if (provider.provider?.category === "Face ID") { @@ -296,6 +313,9 @@ class LoginPage extends React.Component { const oAuthParams = Util.getOAuthGetParameters(); values["type"] = oAuthParams?.responseType ?? this.state.type; + if (this.state.userCode) { + values["userCode"] = this.state.userCode; + } if (oAuthParams?.samlRequest) { values["samlRequest"] = oAuthParams.samlRequest; @@ -479,6 +499,11 @@ class LoginPage extends React.Component { this.props.onLoginSuccess(); } else if (responseType === "code") { this.postCodeLoginAction(res); + } else if (responseType === "device") { + Setting.showMessage("success", "Successful login"); + this.setState({ + userCodeStatus: "success", + }); } else if (responseType === "token" || responseType === "id_token") { if (res.data2) { sessionStorage.setItem("signinUrl", window.location.pathname + window.location.search); @@ -826,6 +851,16 @@ class LoginPage extends React.Component { ); } + if (this.state.userCode && this.state.userCodeStatus === "success") { + return ( + + + ); + } + const showForm = Setting.isPasswordEnabled(application) || Setting.isCodeSigninEnabled(application) || Setting.isWebAuthnEnabled(application) || Setting.isLdapEnabled(application) || Setting.isFaceIdEnabled(application); if (showForm) { let loginWidth = 320; @@ -986,6 +1021,10 @@ class LoginPage extends React.Component { return null; } + if (this.state.userCode && this.state.userCodeStatus === "success") { + return null; + } + return (
@@ -1268,6 +1307,15 @@ class LoginPage extends React.Component { } render() { + if (this.state.userCodeStatus === "expired") { + return + ; + } + const application = this.getApplicationObj(); if (application === undefined) { return null;