mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-03 04:52:11 +00:00
feat: device authorization RFC 8628 (#5646)
* device auth: implement the write events * add grant type device code * fix(init): check if default value implements stringer --------- Co-authored-by: adlerhurst <silvan.reusser@gmail.com>
This commit is contained in:
@@ -101,6 +101,12 @@ func (a *API) RegisterService(ctx context.Context, grpcServer server.Server) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleFunc allows registering a [http.HandlerFunc] on an exact
|
||||
// path, instead of prefix like RegisterHandlerOnPrefix.
|
||||
func (a *API) HandleFunc(path string, f http.HandlerFunc) {
|
||||
a.router.HandleFunc(path, f)
|
||||
}
|
||||
|
||||
// RegisterHandlerOnPrefix registers a http handler on a path prefix
|
||||
// the prefix will not be passed to the actual handler
|
||||
func (a *API) RegisterHandlerOnPrefix(prefix string, handler http.Handler) {
|
||||
|
||||
@@ -136,6 +136,8 @@ func OIDCGrantTypesFromModel(grantTypes []domain.OIDCGrantType) []app_pb.OIDCGra
|
||||
oidcGrantTypes[i] = app_pb.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT
|
||||
case domain.OIDCGrantTypeRefreshToken:
|
||||
oidcGrantTypes[i] = app_pb.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN
|
||||
case domain.OIDCGrantTypeDeviceCode:
|
||||
oidcGrantTypes[i] = app_pb.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE
|
||||
}
|
||||
}
|
||||
return oidcGrantTypes
|
||||
@@ -154,6 +156,8 @@ func OIDCGrantTypesToDomain(grantTypes []app_pb.OIDCGrantType) []domain.OIDCGran
|
||||
oidcGrantTypes[i] = domain.OIDCGrantTypeImplicit
|
||||
case app_pb.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN:
|
||||
oidcGrantTypes[i] = domain.OIDCGrantTypeRefreshToken
|
||||
case app_pb.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE:
|
||||
oidcGrantTypes[i] = domain.OIDCGrantTypeDeviceCode
|
||||
}
|
||||
}
|
||||
return oidcGrantTypes
|
||||
|
||||
@@ -99,15 +99,6 @@ func (a *AuthRequest) GetSubject() string {
|
||||
return a.UserID
|
||||
}
|
||||
|
||||
func (a *AuthRequest) Done() bool {
|
||||
for _, step := range a.PossibleSteps {
|
||||
if step.Type() == domain.NextStepRedirectToCallback {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *AuthRequest) oidc() *domain.AuthRequestOIDC {
|
||||
return a.Request.(*domain.AuthRequestOIDC)
|
||||
}
|
||||
|
||||
@@ -200,6 +200,8 @@ func grantTypeToOIDC(grantType domain.OIDCGrantType) oidc.GrantType {
|
||||
return oidc.GrantTypeImplicit
|
||||
case domain.OIDCGrantTypeRefreshToken:
|
||||
return oidc.GrantTypeRefreshToken
|
||||
case domain.OIDCGrantTypeDeviceCode:
|
||||
return oidc.GrantTypeDeviceCode
|
||||
default:
|
||||
return oidc.GrantTypeCode
|
||||
}
|
||||
|
||||
176
internal/api/oidc/device_auth.go
Normal file
176
internal/api/oidc/device_auth.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
"github.com/zitadel/oidc/v2/pkg/oidc"
|
||||
"github.com/zitadel/oidc/v2/pkg/op"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/ui/login"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/errors"
|
||||
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
||||
)
|
||||
|
||||
const (
|
||||
DeviceAuthDefaultLifetime = 5 * time.Minute
|
||||
DeviceAuthDefaultPollInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
type DeviceAuthorizationConfig struct {
|
||||
Lifetime time.Duration
|
||||
PollInterval time.Duration
|
||||
UserCode *UserCodeConfig
|
||||
}
|
||||
|
||||
type UserCodeConfig struct {
|
||||
CharSet string
|
||||
CharAmount int
|
||||
DashInterval int
|
||||
}
|
||||
|
||||
// toOPConfig converts DeviceAuthorizationConfig to a [op.DeviceAuthorizationConfig],
|
||||
// setting sane defaults for empty values.
|
||||
// Safe to call when c is nil.
|
||||
func (c *DeviceAuthorizationConfig) toOPConfig() op.DeviceAuthorizationConfig {
|
||||
out := op.DeviceAuthorizationConfig{
|
||||
Lifetime: DeviceAuthDefaultLifetime,
|
||||
PollInterval: DeviceAuthDefaultPollInterval,
|
||||
UserFormPath: login.EndpointDeviceAuth,
|
||||
UserCode: op.UserCodeBase20,
|
||||
}
|
||||
if c == nil {
|
||||
return out
|
||||
}
|
||||
if c.Lifetime != 0 {
|
||||
out.Lifetime = c.Lifetime
|
||||
}
|
||||
if c.PollInterval != 0 {
|
||||
out.PollInterval = c.PollInterval
|
||||
}
|
||||
|
||||
if c.UserCode == nil {
|
||||
return out
|
||||
}
|
||||
if c.UserCode.CharSet != "" {
|
||||
out.UserCode.CharSet = c.UserCode.CharSet
|
||||
}
|
||||
if c.UserCode.CharAmount != 0 {
|
||||
out.UserCode.CharAmount = c.UserCode.CharAmount
|
||||
}
|
||||
if c.UserCode.DashInterval != 0 {
|
||||
out.UserCode.DashInterval = c.UserCode.CharAmount
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StoreDeviceAuthorization creates a new Device Authorization request.
|
||||
// Implements the op.DeviceAuthorizationStorage interface.
|
||||
func (o *OPStorage) StoreDeviceAuthorization(ctx context.Context, clientID, deviceCode, userCode string, expires time.Time, scopes []string) (err error) {
|
||||
const logMsg = "store device authorization"
|
||||
logger := logging.WithFields("client_id", clientID, "device_code", deviceCode, "user_code", userCode, "expires", expires, "scopes", scopes)
|
||||
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() {
|
||||
logger.OnError(err).Error(logMsg)
|
||||
span.EndWithError(err)
|
||||
}()
|
||||
|
||||
// TODO(muhlemmer): Remove the following code block with oidc v3
|
||||
// https://github.com/zitadel/oidc/issues/370
|
||||
client, err := o.GetClientByClientID(ctx, clientID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !op.ValidateGrantType(client, oidc.GrantTypeDeviceCode) {
|
||||
return errors.ThrowPermissionDeniedf(nil, "OIDC-et1Ae", "grant type %q not allowed for client", oidc.GrantTypeDeviceCode)
|
||||
}
|
||||
|
||||
scopes, err = o.assertProjectRoleScopes(ctx, clientID, scopes)
|
||||
if err != nil {
|
||||
return errors.ThrowPreconditionFailed(err, "OIDC-She4t", "Errors.Internal")
|
||||
}
|
||||
aggrID, details, err := o.command.AddDeviceAuth(ctx, clientID, deviceCode, userCode, expires, scopes)
|
||||
if err == nil {
|
||||
logger.SetFields("aggregate_id", aggrID, "details", details).Debug(logMsg)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func newDeviceAuthorizationState(d *domain.DeviceAuth) *op.DeviceAuthorizationState {
|
||||
return &op.DeviceAuthorizationState{
|
||||
ClientID: d.ClientID,
|
||||
Scopes: d.Scopes,
|
||||
Expires: d.Expires,
|
||||
Done: d.State.Done(),
|
||||
Subject: d.Subject,
|
||||
Denied: d.State.Denied(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetDeviceAuthorizatonState retieves the current state of the Device Authorization process.
|
||||
// It implements the [op.DeviceAuthorizationStorage] interface and is used by devices that
|
||||
// are polling until they successfully receive a token or we indicate a denied or expired state.
|
||||
// As generated user codes are of low entropy, this implementation also takes care or
|
||||
// device authorization request cleanup, when it has been Approved, Denied or Expired.
|
||||
func (o *OPStorage) GetDeviceAuthorizatonState(ctx context.Context, clientID, deviceCode string) (state *op.DeviceAuthorizationState, err error) {
|
||||
const logMsg = "get device authorization state"
|
||||
logger := logging.WithFields("client_id", clientID, "device_code", deviceCode)
|
||||
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() {
|
||||
if err != nil {
|
||||
logger.WithError(err).Error(logMsg)
|
||||
}
|
||||
span.EndWithError(err)
|
||||
}()
|
||||
|
||||
deviceAuth, err := o.query.DeviceAuthByDeviceCode(ctx, clientID, deviceCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.SetFields(
|
||||
"expires", deviceAuth.Expires, "scopes", deviceAuth.Scopes,
|
||||
"subject", deviceAuth.Subject, "state", deviceAuth.State,
|
||||
).Debug("device authorization state")
|
||||
|
||||
// Cancel the request if it is expired, only if it wasn't Done meanwhile
|
||||
if !deviceAuth.State.Done() && deviceAuth.Expires.Before(time.Now()) {
|
||||
_, err = o.command.CancelDeviceAuth(ctx, deviceAuth.AggregateID, domain.DeviceAuthCanceledExpired)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deviceAuth.State = domain.DeviceAuthStateExpired
|
||||
}
|
||||
|
||||
// When the request is more then initiated, it has been either Approved, Denied or Expired.
|
||||
// At this point we should remove it from the DB to avoid user code conflicts.
|
||||
if deviceAuth.State > domain.DeviceAuthStateInitiated {
|
||||
_, err = o.command.RemoveDeviceAuth(ctx, deviceAuth.AggregateID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return newDeviceAuthorizationState(deviceAuth), nil
|
||||
}
|
||||
|
||||
// TODO(muhlemmer): remove the following methods with oidc v3.
|
||||
// They are actually not used, but are required by the oidc device storage interface.
|
||||
// https://github.com/zitadel/oidc/issues/371
|
||||
func (o *OPStorage) GetDeviceAuthorizationByUserCode(ctx context.Context, userCode string) (*op.DeviceAuthorizationState, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (o *OPStorage) CompleteDeviceAuthorization(ctx context.Context, userCode, subject string) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *OPStorage) DenyDeviceAuthorization(ctx context.Context, userCode string) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO end.
|
||||
@@ -40,6 +40,7 @@ type Config struct {
|
||||
UserAgentCookieConfig *middleware.UserAgentCookieConfig
|
||||
Cache *middleware.CacheConfig
|
||||
CustomEndpoints *EndpointConfig
|
||||
DeviceAuth *DeviceAuthorizationConfig
|
||||
}
|
||||
|
||||
type EndpointConfig struct {
|
||||
@@ -50,6 +51,7 @@ type EndpointConfig struct {
|
||||
Revocation *Endpoint
|
||||
EndSession *Endpoint
|
||||
Keys *Endpoint
|
||||
DeviceAuth *Endpoint
|
||||
}
|
||||
|
||||
type Endpoint struct {
|
||||
@@ -108,6 +110,7 @@ func createOPConfig(config Config, defaultLogoutRedirectURI string, cryptoKey []
|
||||
GrantTypeRefreshToken: config.GrantTypeRefreshToken,
|
||||
RequestObjectSupported: config.RequestObjectSupported,
|
||||
SupportedUILocales: supportedLanguages,
|
||||
DeviceAuthorization: config.DeviceAuth.toOPConfig(),
|
||||
}
|
||||
if cryptoLength := len(cryptoKey); cryptoLength != 32 {
|
||||
return nil, caos_errs.ThrowInternalf(nil, "OIDC-D43gf", "crypto key must be 32 bytes, but is %d", cryptoLength)
|
||||
@@ -165,6 +168,9 @@ func customEndpoints(endpointConfig *EndpointConfig) []op.Option {
|
||||
if endpointConfig.Keys != nil {
|
||||
options = append(options, op.WithCustomKeysEndpoint(op.NewEndpointWithURL(endpointConfig.Keys.Path, endpointConfig.Keys.URL)))
|
||||
}
|
||||
if endpointConfig.DeviceAuth != nil {
|
||||
options = append(options, op.WithCustomDeviceAuthorizationEndpoint(op.NewEndpointWithURL(endpointConfig.DeviceAuth.Path, endpointConfig.DeviceAuth.URL)))
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
|
||||
@@ -63,14 +63,6 @@ func (a *AuthRequest) GetUserID() string {
|
||||
func (a *AuthRequest) GetUserName() string {
|
||||
return a.UserName
|
||||
}
|
||||
func (a *AuthRequest) Done() bool {
|
||||
for _, step := range a.PossibleSteps {
|
||||
if step.Type() == domain.NextStepRedirectToCallback {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func AuthRequestFromBusiness(authReq *domain.AuthRequest) (_ models.AuthRequestInt, err error) {
|
||||
if _, ok := authReq.Request.(*domain.AuthRequestSAML); !ok {
|
||||
|
||||
201
internal/api/ui/login/device_auth.go
Normal file
201
internal/api/ui/login/device_auth.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package login
|
||||
|
||||
import (
|
||||
errs "errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/muhlemmer/gu"
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/api/http/middleware"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
tmplDeviceAuthUserCode = "device-usercode"
|
||||
tmplDeviceAuthAction = "device-action"
|
||||
)
|
||||
|
||||
func (l *Login) renderDeviceAuthUserCode(w http.ResponseWriter, r *http.Request, err error) {
|
||||
var errID, errMessage string
|
||||
if err != nil {
|
||||
logging.WithError(err).Error()
|
||||
errID, errMessage = l.getErrorMessage(r, err)
|
||||
}
|
||||
|
||||
data := l.getBaseData(r, nil, "DeviceAuth.Title", "DeviceAuth.UserCode.Description", errID, errMessage)
|
||||
translator := l.getTranslator(r.Context(), nil)
|
||||
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplDeviceAuthUserCode], data, nil)
|
||||
}
|
||||
|
||||
func (l *Login) renderDeviceAuthAction(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, scopes []string) {
|
||||
data := &struct {
|
||||
baseData
|
||||
AuthRequestID string
|
||||
Username string
|
||||
ClientID string
|
||||
Scopes []string
|
||||
}{
|
||||
baseData: l.getBaseData(r, authReq, "DeviceAuth.Title", "DeviceAuth.Action.Description", "", ""),
|
||||
AuthRequestID: authReq.ID,
|
||||
Username: authReq.UserName,
|
||||
ClientID: authReq.ApplicationID,
|
||||
Scopes: scopes,
|
||||
}
|
||||
|
||||
translator := l.getTranslator(r.Context(), authReq)
|
||||
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplDeviceAuthAction], data, nil)
|
||||
}
|
||||
|
||||
const (
|
||||
deviceAuthAllowed = "allowed"
|
||||
deviceAuthDenied = "denied"
|
||||
)
|
||||
|
||||
// renderDeviceAuthDone renders success.html when the action was allowed and error.html when it was denied.
|
||||
func (l *Login) renderDeviceAuthDone(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, action string) {
|
||||
data := &struct {
|
||||
baseData
|
||||
Message string
|
||||
}{
|
||||
baseData: l.getBaseData(r, authReq, "DeviceAuth.Title", "DeviceAuth.Done.Description", "", ""),
|
||||
}
|
||||
|
||||
translator := l.getTranslator(r.Context(), authReq)
|
||||
switch action {
|
||||
case deviceAuthAllowed:
|
||||
data.Message = translator.LocalizeFromRequest(r, "DeviceAuth.Done.Approved", nil)
|
||||
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplSuccess], data, nil)
|
||||
case deviceAuthDenied:
|
||||
data.ErrMessage = translator.LocalizeFromRequest(r, "DeviceAuth.Done.Denied", nil)
|
||||
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplError], data, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeviceUserCode serves the Device Authorization user code submission form.
|
||||
// The "user_code" may be submitted by URL (GET) or form (POST).
|
||||
// When a "user_code" is received and found through query,
|
||||
// handleDeviceAuthUserCode will create a new AuthRequest in the repository.
|
||||
// The user is then redirected to the /login endpoint to complete authentication.
|
||||
//
|
||||
// The agent ID from the context is set to the authentication request
|
||||
// to ensure the complete login flow is completed from the same browser.
|
||||
func (l *Login) handleDeviceAuthUserCode(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
l.renderDeviceAuthUserCode(w, r, err)
|
||||
return
|
||||
}
|
||||
userCode := r.Form.Get("user_code")
|
||||
if userCode == "" {
|
||||
if prompt, _ := url.QueryUnescape(r.Form.Get("prompt")); prompt != "" {
|
||||
err = errs.New(prompt)
|
||||
}
|
||||
l.renderDeviceAuthUserCode(w, r, err)
|
||||
return
|
||||
}
|
||||
deviceAuth, err := l.query.DeviceAuthByUserCode(ctx, userCode)
|
||||
if err != nil {
|
||||
l.renderDeviceAuthUserCode(w, r, err)
|
||||
return
|
||||
}
|
||||
userAgentID, ok := middleware.UserAgentIDFromCtx(ctx)
|
||||
if !ok {
|
||||
l.renderDeviceAuthUserCode(w, r, errs.New("internal error: agent ID missing"))
|
||||
return
|
||||
}
|
||||
authRequest, err := l.authRepo.CreateAuthRequest(ctx, &domain.AuthRequest{
|
||||
CreationDate: time.Now(),
|
||||
AgentID: userAgentID,
|
||||
ApplicationID: deviceAuth.ClientID,
|
||||
InstanceID: authz.GetInstance(ctx).InstanceID(),
|
||||
Request: &domain.AuthRequestDevice{
|
||||
ID: deviceAuth.AggregateID,
|
||||
DeviceCode: deviceAuth.DeviceCode,
|
||||
UserCode: deviceAuth.UserCode,
|
||||
Scopes: deviceAuth.Scopes,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
l.renderDeviceAuthUserCode(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, l.renderer.pathPrefix+EndpointLogin+"?authRequestID="+authRequest.ID, http.StatusFound)
|
||||
}
|
||||
|
||||
// redirectDeviceAuthStart redirects the user to the start point of
|
||||
// the device authorization flow. A prompt can be set to inform the user
|
||||
// of the reason why they are redirected back.
|
||||
func (l *Login) redirectDeviceAuthStart(w http.ResponseWriter, r *http.Request, prompt string) {
|
||||
values := make(url.Values)
|
||||
values.Set("prompt", url.QueryEscape(prompt))
|
||||
|
||||
url := url.URL{
|
||||
Path: l.renderer.pathPrefix + EndpointDeviceAuth,
|
||||
RawQuery: values.Encode(),
|
||||
}
|
||||
http.Redirect(w, r, url.String(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleDeviceAuthAction is the handler where the user is redirected after login.
|
||||
// The authRequest is checked if the login was indeed completed.
|
||||
// When the action of "allowed" or "denied", the device authorization is updated accordingly.
|
||||
// Else the user is presented with a page where they can choose / submit either action.
|
||||
func (l *Login) handleDeviceAuthAction(w http.ResponseWriter, r *http.Request) {
|
||||
authReq, err := l.getAuthRequest(r)
|
||||
if authReq == nil {
|
||||
err = errors.ThrowInvalidArgument(err, "LOGIN-OLah8", "invalid or missing auth request")
|
||||
l.redirectDeviceAuthStart(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
if !authReq.Done() {
|
||||
l.redirectDeviceAuthStart(w, r, "authentication not completed")
|
||||
return
|
||||
}
|
||||
authDev, ok := authReq.Request.(*domain.AuthRequestDevice)
|
||||
if !ok {
|
||||
l.redirectDeviceAuthStart(w, r, fmt.Sprintf("wrong auth request type: %T", authReq.Request))
|
||||
return
|
||||
}
|
||||
|
||||
action := mux.Vars(r)["action"]
|
||||
switch action {
|
||||
case deviceAuthAllowed:
|
||||
_, err = l.command.ApproveDeviceAuth(r.Context(), authDev.ID, authReq.UserID)
|
||||
case deviceAuthDenied:
|
||||
_, err = l.command.CancelDeviceAuth(r.Context(), authDev.ID, domain.DeviceAuthCanceledDenied)
|
||||
default:
|
||||
l.renderDeviceAuthAction(w, r, authReq, authDev.Scopes)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
l.redirectDeviceAuthStart(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
l.renderDeviceAuthDone(w, r, authReq, action)
|
||||
}
|
||||
|
||||
// deviceAuthCallbackURL creates the callback URL with which the user
|
||||
// is redirected back to the device authorization flow.
|
||||
func (l *Login) deviceAuthCallbackURL(authRequestID string) string {
|
||||
return l.renderer.pathPrefix + EndpointDeviceAuthAction + "?authRequestID=" + authRequestID
|
||||
}
|
||||
|
||||
// RedirectDeviceAuthToPrefix allows users to use https://domain.com/device without the /ui/login prefix
|
||||
// and redirects them to the prefixed endpoint.
|
||||
// [rfc 8628](https://www.rfc-editor.org/rfc/rfc8628#section-3.2) recommends the URL to be as short as possible.
|
||||
func RedirectDeviceAuthToPrefix(w http.ResponseWriter, r *http.Request) {
|
||||
target := gu.PtrCopy(r.URL)
|
||||
target.Path = HandlerPrefix + EndpointDeviceAuth
|
||||
http.Redirect(w, r, target.String(), http.StatusFound)
|
||||
}
|
||||
@@ -69,6 +69,8 @@ func (l *Login) authRequestCallback(ctx context.Context, authReq *domain.AuthReq
|
||||
return l.oidcAuthCallbackURL(ctx, authReq.ID), nil
|
||||
case *domain.AuthRequestSAML:
|
||||
return l.samlAuthCallbackURL(ctx, authReq.ID), nil
|
||||
case *domain.AuthRequestDevice:
|
||||
return l.deviceAuthCallbackURL(authReq.ID), nil
|
||||
default:
|
||||
return "", caos_errs.ThrowInternal(nil, "LOGIN-rhjQF", "Errors.AuthRequest.RequestTypeNotSupported")
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
tmplError = "error"
|
||||
tmplError = "error"
|
||||
tmplSuccess = "success"
|
||||
)
|
||||
|
||||
type Renderer struct {
|
||||
@@ -45,6 +46,7 @@ func CreateRenderer(pathPrefix string, staticDir http.FileSystem, staticStorage
|
||||
}
|
||||
tmplMapping := map[string]string{
|
||||
tmplError: "error.html",
|
||||
tmplSuccess: "success.html",
|
||||
tmplLogin: "login.html",
|
||||
tmplUserSelection: "select_user.html",
|
||||
tmplPassword: "password.html",
|
||||
@@ -77,6 +79,8 @@ func CreateRenderer(pathPrefix string, staticDir http.FileSystem, staticStorage
|
||||
tmplExternalNotFoundOption: "external_not_found_option.html",
|
||||
tmplLoginSuccess: "login_success.html",
|
||||
tmplLDAPLogin: "ldap_login.html",
|
||||
tmplDeviceAuthUserCode: "device_usercode.html",
|
||||
tmplDeviceAuthAction: "device_action.html",
|
||||
}
|
||||
funcs := map[string]interface{}{
|
||||
"resourceUrl": func(file string) string {
|
||||
@@ -323,6 +327,7 @@ func (l *Login) chooseNextStep(w http.ResponseWriter, r *http.Request, authReq *
|
||||
func (l *Login) renderInternalError(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) {
|
||||
var msg string
|
||||
if err != nil {
|
||||
logging.WithError(err).WithField("auth_req_id", authReq.ID).Error()
|
||||
_, msg = l.getErrorMessage(r, err)
|
||||
}
|
||||
data := l.getBaseData(r, authReq, "Errors.Internal", "", "Internal", msg)
|
||||
|
||||
@@ -46,6 +46,9 @@ const (
|
||||
|
||||
EndpointResources = "/resources"
|
||||
EndpointDynamicResources = "/resources/dynamic"
|
||||
|
||||
EndpointDeviceAuth = "/device"
|
||||
EndpointDeviceAuthAction = "/device/{action}"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -107,5 +110,7 @@ func CreateRouter(login *Login, staticDir http.FileSystem, interceptors ...mux.M
|
||||
router.HandleFunc(EndpointLDAPLogin, login.handleLDAP).Methods(http.MethodGet)
|
||||
router.HandleFunc(EndpointLDAPCallback, login.handleLDAPCallback).Methods(http.MethodPost)
|
||||
router.SkipClean(true).Handle("", http.RedirectHandler(HandlerPrefix+"/", http.StatusMovedPermanently))
|
||||
router.HandleFunc(EndpointDeviceAuth, login.handleDeviceAuthUserCode).Methods(http.MethodGet, http.MethodPost)
|
||||
router.HandleFunc(EndpointDeviceAuthAction, login.handleDeviceAuthAction).Methods(http.MethodGet, http.MethodPost)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -317,6 +317,24 @@ ExternalNotFound:
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
|
||||
DeviceAuth:
|
||||
Title: Geräteautorisierung
|
||||
UserCode:
|
||||
Label: Benutzercode
|
||||
Description: Geben Sie den auf dem Gerät angezeigten Benutzercode ein
|
||||
ButtonNext: weiter
|
||||
Action:
|
||||
Description: Gerätezugriff erlauben
|
||||
GrantDevice: Sie sind dabei, das Gerät zu erlauben
|
||||
AccessToScopes: Zugriff auf die folgenden Daten
|
||||
Button:
|
||||
Allow: erlauben
|
||||
Deny: verweigern
|
||||
Done:
|
||||
Description: Abgeschlossen
|
||||
Approved: Gerätezulassung genehmigt. Sie können jetzt zum Gerät zurückkehren.
|
||||
Denied: Geräteautorisierung verweigert. Sie können jetzt zum Gerät zurückkehren.
|
||||
|
||||
Footer:
|
||||
PoweredBy: Powered By
|
||||
Tos: AGB
|
||||
@@ -425,5 +443,7 @@ Errors:
|
||||
Org:
|
||||
LoginPolicy:
|
||||
RegistrationNotAllowed: Registrierung ist nicht erlaubt
|
||||
DeviceAuth:
|
||||
NotExisting: Benutzercode existiert nicht
|
||||
|
||||
optional: (optional)
|
||||
|
||||
@@ -317,6 +317,24 @@ ExternalNotFound:
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
|
||||
DeviceAuth:
|
||||
Title: Device Authorization
|
||||
UserCode:
|
||||
Label: User Code
|
||||
Description: Enter the user code presented on the device.
|
||||
ButtonNext: next
|
||||
Action:
|
||||
Description: Grant device access.
|
||||
GrantDevice: you are about to grant device
|
||||
AccessToScopes: access to the following scopes
|
||||
Button:
|
||||
Allow: allow
|
||||
Deny: deny
|
||||
Done:
|
||||
Description: Done.
|
||||
Approved: Device authorization approved. You can now return to the device.
|
||||
Denied: Device authorization denied. You can now return to the device.
|
||||
|
||||
Footer:
|
||||
PoweredBy: Powered By
|
||||
Tos: TOS
|
||||
@@ -425,5 +443,7 @@ Errors:
|
||||
Org:
|
||||
LoginPolicy:
|
||||
RegistrationNotAllowed: Registration is not allowed
|
||||
DeviceAuth:
|
||||
NotExisting: User Code doesn't exist
|
||||
|
||||
optional: (optional)
|
||||
|
||||
@@ -317,6 +317,24 @@ ExternalNotFound:
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
|
||||
DeviceAuth:
|
||||
Title: Autorisation de l'appareil
|
||||
UserCode:
|
||||
Label: Code d'utilisateur
|
||||
Description: Saisissez le code utilisateur présenté sur l'appareil.
|
||||
ButtonNext: suivant
|
||||
Action:
|
||||
Description: Accordez l'accès à l'appareil.
|
||||
GrantDevice: vous êtes sur le point d'accorder un appareil
|
||||
AccessToScopes: accès aux périmètres suivants
|
||||
Button:
|
||||
Allow: permettre
|
||||
Deny: refuser
|
||||
Done:
|
||||
Description: Fait.
|
||||
Approved: Autorisation de l'appareil approuvée. Vous pouvez maintenant retourner à l'appareil.
|
||||
Denied: Autorisation de l'appareil refusée. Vous pouvez maintenant retourner à l'appareil.
|
||||
|
||||
Footer:
|
||||
PoweredBy: Promulgué par
|
||||
Tos: TOS
|
||||
@@ -425,5 +443,7 @@ Errors:
|
||||
Org:
|
||||
LoginPolicy:
|
||||
RegistrationNotAllowed: L'enregistrement n'est pas autorisé
|
||||
DeviceAuth:
|
||||
NotExisting: Le code utilisateur n'existe pas
|
||||
|
||||
optional: (facultatif)
|
||||
|
||||
@@ -317,6 +317,24 @@ ExternalNotFound:
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
|
||||
DeviceAuth:
|
||||
Title: Autorizzazione del dispositivo
|
||||
UserCode:
|
||||
Label: Codice utente
|
||||
Description: Inserire il codice utente presentato sul dispositivo.
|
||||
ButtonNext: prossimo
|
||||
Action:
|
||||
Description: Concedi l'accesso al dispositivo.
|
||||
GrantDevice: stai per concedere il dispositivo
|
||||
AccessToScopes: accesso ai seguenti ambiti
|
||||
Button:
|
||||
Allow: permettere
|
||||
Deny: negare
|
||||
Done:
|
||||
Description: Fatto.
|
||||
Approved: Autorizzazione del dispositivo approvata. Ora puoi tornare al dispositivo.
|
||||
Denied: Autorizzazione dispositivo negata. Ora puoi tornare al dispositivo.
|
||||
|
||||
Footer:
|
||||
PoweredBy: Alimentato da
|
||||
Tos: Termini di servizio
|
||||
@@ -425,5 +443,7 @@ Errors:
|
||||
Org:
|
||||
LoginPolicy:
|
||||
RegistrationNotAllowed: la registrazione non è consentita.
|
||||
DeviceAuth:
|
||||
NotExisting: Il codice utente non esiste
|
||||
|
||||
optional: (opzionale)
|
||||
|
||||
@@ -309,6 +309,24 @@ ExternalNotFound:
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
|
||||
DeviceAuth:
|
||||
Title: デバイス認証
|
||||
UserCode:
|
||||
Label: ユーザーコード
|
||||
Description: デバイスに表示されたユーザー コードを入力します。
|
||||
ButtonNext: 次
|
||||
Action:
|
||||
Description: デバイスへのアクセスを許可します。
|
||||
GrantDevice: デバイスを許可しようとしています
|
||||
AccessToScopes: 次のスコープへのアクセス
|
||||
Button:
|
||||
Allow: 許可する
|
||||
Deny: 拒否
|
||||
Done:
|
||||
Description: 終わり。
|
||||
Approved: デバイス認証が承認されました。 これで、デバイスに戻ることができます。
|
||||
Denied: デバイス認証が拒否されました。 これで、デバイスに戻ることができます。
|
||||
|
||||
Footer:
|
||||
PoweredBy: Powered By
|
||||
Tos: TOS
|
||||
@@ -385,5 +403,7 @@ Errors:
|
||||
IAM:
|
||||
LockoutPolicy:
|
||||
NotExisting: ロックアウトポリシーが存在しません
|
||||
DeviceAuth:
|
||||
NotExisting: ユーザーコードが存在しません
|
||||
|
||||
optional: "(オプション)"
|
||||
|
||||
@@ -317,6 +317,24 @@ ExternalNotFound:
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
|
||||
DeviceAuth:
|
||||
Title: Autoryzacja urządzenia
|
||||
UserCode:
|
||||
Label: Kod użytkownika
|
||||
Description: Wprowadź kod użytkownika prezentowany na urządzeniu.
|
||||
ButtonNext: Następny
|
||||
Action:
|
||||
Description: Przyznaj dostęp do urządzenia.
|
||||
GrantDevice: zamierzasz przyznać urządzenie
|
||||
AccessToScopes: dostęp do następujących zakresów
|
||||
Button:
|
||||
Allow: umożliwić
|
||||
Deny: zaprzeczyć
|
||||
Done:
|
||||
Description: Zrobione.
|
||||
Approved: Zatwierdzono autoryzację urządzenia. Możesz teraz wrócić do urządzenia.
|
||||
Denied: Odmowa autoryzacji urządzenia. Możesz teraz wrócić do urządzenia.
|
||||
|
||||
Footer:
|
||||
PoweredBy: Obsługiwane przez
|
||||
Tos: TOS
|
||||
@@ -425,5 +443,7 @@ Errors:
|
||||
Org:
|
||||
LoginPolicy:
|
||||
RegistrationNotAllowed: Rejestracja nie jest dozwolona
|
||||
DeviceAuth:
|
||||
NotExisting: Kod użytkownika nie istnieje
|
||||
|
||||
optional: (opcjonalny)
|
||||
|
||||
@@ -317,6 +317,24 @@ ExternalNotFound:
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
|
||||
DeviceAuth:
|
||||
Title: 设备授权
|
||||
UserCode:
|
||||
Label: 用户代码
|
||||
Description: 输入设备上显示的用户代码。
|
||||
ButtonNext: 下一个
|
||||
Action:
|
||||
Description: 授予设备访问权限。
|
||||
GrantDevice: 您即将授予设备
|
||||
AccessToScopes: 访问以下范围
|
||||
Button:
|
||||
Allow: 允许
|
||||
Deny: 否定
|
||||
Done:
|
||||
Description: 完毕。
|
||||
Approved: 设备授权已批准。 您现在可以返回设备。
|
||||
Denied: 设备授权被拒绝。 您现在可以返回设备。
|
||||
|
||||
Footer:
|
||||
PoweredBy: Powered By
|
||||
Tos: 服务条款
|
||||
@@ -425,5 +443,7 @@ Errors:
|
||||
Org:
|
||||
LoginPolicy:
|
||||
RegistrationNotAllowed: 不允许注册
|
||||
DeviceAuth:
|
||||
NotExisting: 用户代码不存在
|
||||
|
||||
optional: (可选)
|
||||
|
||||
18
internal/api/ui/login/static/templates/device_action.html
Normal file
18
internal/api/ui/login/static/templates/device_action.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{{template "main-top" .}}
|
||||
|
||||
<h1>{{.Title}}</h1>
|
||||
<p>
|
||||
{{.Username}}, {{t "DeviceAuth.Action.GrantDevice"}} {{.ClientID}} {{t "DeviceAuth.Action.AccessToScopes"}}: {{.Scopes}}.
|
||||
</p>
|
||||
<form method="POST">
|
||||
{{ .CSRF }}
|
||||
<input type="hidden" name="authRequestID" value="{{.AuthRequestID}}">
|
||||
<button class="lgn-raised-button lgn-primary left" type="submit" formaction="./allowed">
|
||||
{{t "DeviceAuth.Action.Button.Allow"}}
|
||||
</button>
|
||||
<button class="lgn-raised-button lgn-warn right" type="submit" formaction="./denied">
|
||||
{{t "DeviceAuth.Action.Button.Deny"}}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{{template "main-bottom" .}}
|
||||
21
internal/api/ui/login/static/templates/device_usercode.html
Normal file
21
internal/api/ui/login/static/templates/device_usercode.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{{template "main-top" .}}
|
||||
|
||||
<h1>{{.Title}}</h1>
|
||||
<form method="POST">
|
||||
|
||||
{{ .CSRF }}
|
||||
|
||||
<div class="fields">
|
||||
<label class="lgn-label" for="user_code">{{t "DeviceAuth.UserCode.Label"}}</label>
|
||||
<input class="lgn-input" id="user_code" name="user_code" autofocus required{{if .ErrMessage}} shake{{end}}>
|
||||
</div>
|
||||
|
||||
{{template "error-message" .}}
|
||||
|
||||
<div class="lgn-actions">
|
||||
<span class="fill-space"></span>
|
||||
<button id="submit-button" class="lgn-raised-button lgn-primary right" type="submit">{{t "DeviceAuth.UserCode.ButtonNext"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{template "main-bottom" .}}
|
||||
12
internal/api/ui/login/static/templates/success.html
Normal file
12
internal/api/ui/login/static/templates/success.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{{template "main-top" .}}
|
||||
|
||||
<div class="lgn-head">
|
||||
<div class="lgn-actions">
|
||||
<i class="lgn-icon-check-solid lgn-primary"></i>
|
||||
<p class="lgn-error-message">
|
||||
{{ .Message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "main-bottom" .}}
|
||||
@@ -1,3 +1,3 @@
|
||||
package statik
|
||||
|
||||
//go:generate statik -src=../static -dest=.. -ns=login
|
||||
//go:generate statik -f -src=../static -dest=.. -ns=login
|
||||
|
||||
Reference in New Issue
Block a user