implement RFC 8628: Device authorization grant
This commit is contained in:
parent
03f71a67c2
commit
2342f208ef
29 changed files with 1968 additions and 97 deletions
191
example/server/exampleop/device.go
Normal file
191
example/server/exampleop/device.go
Normal file
|
@ -0,0 +1,191 @@
|
|||
package exampleop
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/zitadel/oidc/v2/pkg/op"
|
||||
)
|
||||
|
||||
type deviceAuthenticate interface {
|
||||
CheckUsernamePasswordSimple(username, password string) error
|
||||
op.DeviceAuthorizationStorage
|
||||
}
|
||||
|
||||
type deviceLogin struct {
|
||||
storage deviceAuthenticate
|
||||
cookie *securecookie.SecureCookie
|
||||
}
|
||||
|
||||
func registerDeviceAuth(storage deviceAuthenticate, router *mux.Router) {
|
||||
l := &deviceLogin{
|
||||
storage: storage,
|
||||
cookie: securecookie.New(securecookie.GenerateRandomKey(32), nil),
|
||||
}
|
||||
|
||||
router.HandleFunc("", l.userCodeHandler)
|
||||
router.Path("/login").Methods(http.MethodPost).HandlerFunc(l.loginHandler)
|
||||
router.HandleFunc("/confirm", l.confirmHandler)
|
||||
}
|
||||
|
||||
func renderUserCode(w io.Writer, err error) {
|
||||
data := struct {
|
||||
Error string
|
||||
}{
|
||||
Error: errMsg(err),
|
||||
}
|
||||
|
||||
if err := templates.ExecuteTemplate(w, "usercode", data); err != nil {
|
||||
logrus.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func renderDeviceLogin(w http.ResponseWriter, userCode string, err error) {
|
||||
data := &struct {
|
||||
UserCode string
|
||||
Error string
|
||||
}{
|
||||
UserCode: userCode,
|
||||
Error: errMsg(err),
|
||||
}
|
||||
if err = templates.ExecuteTemplate(w, "device_login", data); err != nil {
|
||||
logrus.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func renderConfirmPage(w http.ResponseWriter, username, clientID string, scopes []string) {
|
||||
data := &struct {
|
||||
Username string
|
||||
ClientID string
|
||||
Scopes []string
|
||||
}{
|
||||
Username: username,
|
||||
ClientID: clientID,
|
||||
Scopes: scopes,
|
||||
}
|
||||
if err := templates.ExecuteTemplate(w, "confirm_device", data); err != nil {
|
||||
logrus.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *deviceLogin) userCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
renderUserCode(w, err)
|
||||
return
|
||||
}
|
||||
userCode := r.Form.Get("user_code")
|
||||
if userCode == "" {
|
||||
if prompt, _ := url.QueryUnescape(r.Form.Get("prompt")); prompt != "" {
|
||||
err = errors.New(prompt)
|
||||
}
|
||||
renderUserCode(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
renderDeviceLogin(w, userCode, nil)
|
||||
}
|
||||
|
||||
func redirectBack(w http.ResponseWriter, r *http.Request, prompt string) {
|
||||
values := make(url.Values)
|
||||
values.Set("prompt", url.QueryEscape(prompt))
|
||||
|
||||
url := url.URL{
|
||||
Path: "/device",
|
||||
RawQuery: values.Encode(),
|
||||
}
|
||||
http.Redirect(w, r, url.String(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
const userCodeCookieName = "user_code"
|
||||
|
||||
type userCodeCookie struct {
|
||||
UserCode string
|
||||
UserName string
|
||||
}
|
||||
|
||||
func (d *deviceLogin) loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
redirectBack(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
userCode := r.PostForm.Get("user_code")
|
||||
if userCode == "" {
|
||||
redirectBack(w, r, "missing user_code in request")
|
||||
return
|
||||
}
|
||||
username := r.PostForm.Get("username")
|
||||
if username == "" {
|
||||
redirectBack(w, r, "missing username in request")
|
||||
return
|
||||
}
|
||||
password := r.PostForm.Get("password")
|
||||
if password == "" {
|
||||
redirectBack(w, r, "missing password in request")
|
||||
return
|
||||
}
|
||||
|
||||
if err := d.storage.CheckUsernamePasswordSimple(username, password); err != nil {
|
||||
redirectBack(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
state, err := d.storage.GetDeviceAuthorizationByUserCode(r.Context(), userCode)
|
||||
if err != nil {
|
||||
redirectBack(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
encoded, err := d.cookie.Encode(userCodeCookieName, userCodeCookie{userCode, username})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cookie := &http.Cookie{
|
||||
Name: userCodeCookieName,
|
||||
Value: encoded,
|
||||
Path: "/",
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
renderConfirmPage(w, username, state.ClientID, state.Scopes)
|
||||
}
|
||||
|
||||
func (d *deviceLogin) confirmHandler(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(userCodeCookieName)
|
||||
if err != nil {
|
||||
redirectBack(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
data := new(userCodeCookie)
|
||||
if err = d.cookie.Decode(userCodeCookieName, cookie.Value, &data); err != nil {
|
||||
redirectBack(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
if err = r.ParseForm(); err != nil {
|
||||
redirectBack(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
action := r.Form.Get("action")
|
||||
switch action {
|
||||
case "allowed":
|
||||
err = d.storage.CompleteDeviceAuthorization(r.Context(), data.UserCode, data.UserName)
|
||||
case "denied":
|
||||
err = d.storage.DenyDeviceAuthorization(r.Context(), data.UserCode)
|
||||
default:
|
||||
err = errors.New("action must be one of \"allow\" or \"deny\"")
|
||||
}
|
||||
if err != nil {
|
||||
redirectBack(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "Device authorization %s. You can now return to the device", action)
|
||||
}
|
|
@ -3,45 +3,11 @@ package exampleop
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
const (
|
||||
queryAuthRequestID = "authRequestID"
|
||||
)
|
||||
|
||||
var loginTmpl, _ = template.New("login").Parse(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Login</title>
|
||||
</head>
|
||||
<body style="display: flex; align-items: center; justify-content: center; height: 100vh;">
|
||||
<form method="POST" action="/login/username" style="height: 200px; width: 200px;">
|
||||
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
|
||||
<div>
|
||||
<label for="username">Username:</label>
|
||||
<input id="username" name="username" style="width: 100%">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password">Password:</label>
|
||||
<input id="password" name="password" style="width: 100%">
|
||||
</div>
|
||||
|
||||
<p style="color:red; min-height: 1rem;">{{.Error}}</p>
|
||||
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>`)
|
||||
|
||||
type login struct {
|
||||
authenticate authenticate
|
||||
router *mux.Router
|
||||
|
@ -74,23 +40,19 @@ func (l *login) loginHandler(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
// the oidc package will pass the id of the auth request as query parameter
|
||||
// we will use this id through the login process and therefore pass it to the login page
|
||||
// we will use this id through the login process and therefore pass it to the login page
|
||||
renderLogin(w, r.FormValue(queryAuthRequestID), nil)
|
||||
}
|
||||
|
||||
func renderLogin(w http.ResponseWriter, id string, err error) {
|
||||
var errMsg string
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
data := &struct {
|
||||
ID string
|
||||
Error string
|
||||
}{
|
||||
ID: id,
|
||||
Error: errMsg,
|
||||
Error: errMsg(err),
|
||||
}
|
||||
err = loginTmpl.Execute(w, data)
|
||||
err = templates.ExecuteTemplate(w, "login", data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"crypto/sha256"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"golang.org/x/text/language"
|
||||
|
@ -27,7 +28,8 @@ func init() {
|
|||
|
||||
type Storage interface {
|
||||
op.Storage
|
||||
CheckUsernamePassword(username, password, id string) error
|
||||
authenticate
|
||||
deviceAuthenticate
|
||||
}
|
||||
|
||||
// SetupServer creates an OIDC server with Issuer=http://localhost:<port>
|
||||
|
@ -62,6 +64,9 @@ func SetupServer(ctx context.Context, issuer string, storage Storage) *mux.Route
|
|||
// so we will direct all calls to /login to the login UI
|
||||
router.PathPrefix("/login/").Handler(http.StripPrefix("/login", l.router))
|
||||
|
||||
router.PathPrefix("/device").Subrouter()
|
||||
registerDeviceAuth(storage, router.PathPrefix("/device").Subrouter())
|
||||
|
||||
// we register the http handler of the OP on the root, so that the discovery endpoint (/.well-known/openid-configuration)
|
||||
// is served on the correct path
|
||||
//
|
||||
|
@ -99,6 +104,13 @@ func newOP(ctx context.Context, storage op.Storage, issuer string, key [32]byte)
|
|||
|
||||
// this example has only static texts (in English), so we'll set the here accordingly
|
||||
SupportedUILocales: []language.Tag{language.English},
|
||||
|
||||
DeviceAuthorization: op.DeviceAuthorizationConfig{
|
||||
Lifetime: 5 * time.Minute,
|
||||
PollInterval: 5 * time.Second,
|
||||
UserFormURL: issuer + "device",
|
||||
UserCode: op.UserCodeBase20,
|
||||
},
|
||||
}
|
||||
handler, err := op.NewOpenIDProvider(ctx, issuer, config, storage,
|
||||
//we must explicitly allow the use of the http issuer
|
||||
|
|
26
example/server/exampleop/templates.go
Normal file
26
example/server/exampleop/templates.go
Normal file
|
@ -0,0 +1,26 @@
|
|||
package exampleop
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed templates
|
||||
templateFS embed.FS
|
||||
templates = template.Must(template.ParseFS(templateFS, "templates/*.html"))
|
||||
)
|
||||
|
||||
const (
|
||||
queryAuthRequestID = "authRequestID"
|
||||
)
|
||||
|
||||
func errMsg(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
logrus.Error(err)
|
||||
return err.Error()
|
||||
}
|
25
example/server/exampleop/templates/confirm_device.html
Normal file
25
example/server/exampleop/templates/confirm_device.html
Normal file
|
@ -0,0 +1,25 @@
|
|||
{{ define "confirm_device" -}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Confirm device authorization</title>
|
||||
<style>
|
||||
.green{
|
||||
background-color: green
|
||||
}
|
||||
.red{
|
||||
background-color: red
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome back {{.Username}}!</h1>
|
||||
<p>
|
||||
You are about to grant device {{.ClientID}} access to the following scopes: {{.Scopes}}.
|
||||
</p>
|
||||
<button onclick="location.href='./confirm?action=allowed'" type="button" class="green">Allow</button>
|
||||
<button onclick="location.href='./confirm?action=denied'" type="button" class="red">Deny</button>
|
||||
</body>
|
||||
</html>
|
||||
{{- end }}
|
29
example/server/exampleop/templates/device_login.html
Normal file
29
example/server/exampleop/templates/device_login.html
Normal file
|
@ -0,0 +1,29 @@
|
|||
{{ define "device_login" -}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Login</title>
|
||||
</head>
|
||||
<body style="display: flex; align-items: center; justify-content: center; height: 100vh;">
|
||||
<form method="POST" action="/device/login" style="height: 200px; width: 200px;">
|
||||
|
||||
<input type="hidden" name="user_code" value="{{.UserCode}}">
|
||||
|
||||
<div>
|
||||
<label for="username">Username:</label>
|
||||
<input id="username" name="username" style="width: 100%">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password">Password:</label>
|
||||
<input id="password" name="password" style="width: 100%">
|
||||
</div>
|
||||
|
||||
<p style="color:red; min-height: 1rem;">{{.Error}}</p>
|
||||
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
{{- end }}
|
29
example/server/exampleop/templates/login.html
Normal file
29
example/server/exampleop/templates/login.html
Normal file
|
@ -0,0 +1,29 @@
|
|||
{{ define "login" -}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Login</title>
|
||||
</head>
|
||||
<body style="display: flex; align-items: center; justify-content: center; height: 100vh;">
|
||||
<form method="POST" action="/login/username" style="height: 200px; width: 200px;">
|
||||
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
|
||||
<div>
|
||||
<label for="username">Username:</label>
|
||||
<input id="username" name="username" style="width: 100%">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password">Password:</label>
|
||||
<input id="password" name="password" style="width: 100%">
|
||||
</div>
|
||||
|
||||
<p style="color:red; min-height: 1rem;">{{.Error}}</p>
|
||||
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>`
|
||||
{{- end }}
|
21
example/server/exampleop/templates/usercode.html
Normal file
21
example/server/exampleop/templates/usercode.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
{{ define "usercode" -}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Device authorization</title>
|
||||
</head>
|
||||
<body style="display: flex; align-items: center; justify-content: center; height: 100vh;">
|
||||
<form method="POST" style="height: 200px; width: 200px;">
|
||||
<h1>Device authorization</h1>
|
||||
<div>
|
||||
<label for="user_code">Code:</label>
|
||||
<input id="user_code" name="user_code" style="width: 100%">
|
||||
</div>
|
||||
<p style="color:red; min-height: 1rem;">{{.Error}}</p>
|
||||
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
{{- end }}
|
|
@ -44,6 +44,8 @@ type Storage struct {
|
|||
services map[string]Service
|
||||
refreshTokens map[string]*RefreshToken
|
||||
signingKey signingKey
|
||||
deviceCodes map[string]deviceAuthorizationEntry
|
||||
userCodes map[string]string
|
||||
}
|
||||
|
||||
type signingKey struct {
|
||||
|
@ -105,6 +107,8 @@ func NewStorage(userStore UserStore) *Storage {
|
|||
algorithm: jose.RS256,
|
||||
key: key,
|
||||
},
|
||||
deviceCodes: make(map[string]deviceAuthorizationEntry),
|
||||
userCodes: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -135,6 +139,17 @@ func (s *Storage) CheckUsernamePassword(username, password, id string) error {
|
|||
return fmt.Errorf("username or password wrong")
|
||||
}
|
||||
|
||||
func (s *Storage) CheckUsernamePasswordSimple(username, password string) error {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
user := s.userStore.GetUserByUsername(username)
|
||||
if user != nil && user.Password == password {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("username or password wrong")
|
||||
}
|
||||
|
||||
// CreateAuthRequest implements the op.Storage interface
|
||||
// it will be called after parsing and validation of the authentication request
|
||||
func (s *Storage) CreateAuthRequest(ctx context.Context, authReq *oidc.AuthRequest, userID string) (op.AuthRequest, error) {
|
||||
|
@ -735,3 +750,85 @@ func appendClaim(claims map[string]interface{}, claim string, value interface{})
|
|||
claims[claim] = value
|
||||
return claims
|
||||
}
|
||||
|
||||
type deviceAuthorizationEntry struct {
|
||||
deviceCode string
|
||||
userCode string
|
||||
state *op.DeviceAuthorizationState
|
||||
}
|
||||
|
||||
func (s *Storage) StoreDeviceAuthorization(ctx context.Context, clientID, deviceCode, userCode string, expires time.Time, scopes []string) error {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
if _, ok := s.clients[clientID]; !ok {
|
||||
return errors.New("client not found")
|
||||
}
|
||||
|
||||
if _, ok := s.userCodes[userCode]; ok {
|
||||
return op.ErrDuplicateUserCode
|
||||
}
|
||||
|
||||
s.deviceCodes[deviceCode] = deviceAuthorizationEntry{
|
||||
deviceCode: deviceCode,
|
||||
userCode: userCode,
|
||||
state: &op.DeviceAuthorizationState{
|
||||
ClientID: clientID,
|
||||
Scopes: scopes,
|
||||
Expires: expires,
|
||||
},
|
||||
}
|
||||
|
||||
s.userCodes[userCode] = deviceCode
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) GetDeviceAuthorizatonState(ctx context.Context, clientID, deviceCode string) (*op.DeviceAuthorizationState, error) {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
entry, ok := s.deviceCodes[deviceCode]
|
||||
if !ok || entry.state.ClientID != clientID {
|
||||
return nil, errors.New("device code not found for client") // is there a standard not found error in the framework?
|
||||
}
|
||||
|
||||
return entry.state, nil
|
||||
}
|
||||
|
||||
func (s *Storage) GetDeviceAuthorizationByUserCode(ctx context.Context, userCode string) (*op.DeviceAuthorizationState, error) {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
entry, ok := s.deviceCodes[s.userCodes[userCode]]
|
||||
if !ok {
|
||||
return nil, errors.New("user code not found")
|
||||
}
|
||||
|
||||
return entry.state, nil
|
||||
}
|
||||
|
||||
func (s *Storage) CompleteDeviceAuthorization(ctx context.Context, userCode, subject string) error {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
entry, ok := s.deviceCodes[s.userCodes[userCode]]
|
||||
if !ok {
|
||||
return errors.New("user code not found")
|
||||
}
|
||||
|
||||
entry.state.Subject = subject
|
||||
entry.state.Done = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) DenyDeviceAuthorization(ctx context.Context, userCode string) error {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
s.deviceCodes[s.userCodes[userCode]].state.Denied = true
|
||||
return nil
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue