authenticate: add support for webauthn (#2688)

* authenticate: add support for webauthn

* remove rfc4648 library due to missing LICENSE

* fix test

* put state function in separate function
This commit is contained in:
Caleb Doxsey 2021-10-20 13:18:34 -06:00 committed by GitHub
parent 3051ad77e0
commit 1162585471
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 855 additions and 18 deletions

View file

@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"html/template"
"net/url"
"github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/frontend"
@ -125,3 +126,36 @@ func (a *Authenticate) updateProvider(cfg *config.Config) error {
return nil
}
func (a *Authenticate) getWebAuthnURL(values url.Values) (*url.URL, error) {
uri, err := a.options.Load().GetAuthenticateURL()
if err != nil {
return nil, err
}
uri = uri.ResolveReference(&url.URL{
Path: "/.pomerium/webauthn",
RawQuery: buildURLValues(values, url.Values{
urlutil.QueryDeviceType: {"default"},
urlutil.QueryEnrollmentToken: nil,
urlutil.QueryRedirectURI: {uri.ResolveReference(&url.URL{
Path: "/.pomerium/",
}).String()},
}).Encode(),
})
return urlutil.NewSignedURL(a.state.Load().sharedKey, uri).Sign(), nil
}
// buildURLValues creates a new url.Values map by traversing the keys in `defaults` and using the values
// from `values` if they exist, otherwise the provided defaults
func buildURLValues(values, defaults url.Values) url.Values {
result := make(url.Values)
for k, vs := range defaults {
if values.Has(k) {
result[k] = values[k]
} else if vs != nil {
result[k] = vs
}
}
return result
}

View file

@ -18,6 +18,7 @@ import (
"golang.org/x/oauth2"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/pomerium/pomerium/authenticate/handlers/webauthn"
"github.com/pomerium/pomerium/internal/httputil"
"github.com/pomerium/pomerium/internal/identity"
"github.com/pomerium/pomerium/internal/identity/manager"
@ -28,6 +29,7 @@ import (
"github.com/pomerium/pomerium/internal/telemetry/trace"
"github.com/pomerium/pomerium/internal/urlutil"
"github.com/pomerium/pomerium/pkg/cryptutil"
"github.com/pomerium/pomerium/pkg/grpc/device"
"github.com/pomerium/pomerium/pkg/grpc/directory"
"github.com/pomerium/pomerium/pkg/grpc/session"
"github.com/pomerium/pomerium/pkg/grpc/user"
@ -93,6 +95,7 @@ func (a *Authenticate) mountDashboard(r *mux.Router) {
sr.Path("/").Handler(a.requireValidSignatureOnRedirect(a.userInfo))
sr.Path("/sign_in").Handler(a.requireValidSignature(a.SignIn))
sr.Path("/sign_out").Handler(a.requireValidSignature(a.SignOut))
sr.Path("/webauthn").Handler(webauthn.New(a.getWebauthnState))
}
func (a *Authenticate) mountWellKnown(r *mux.Router) {
@ -451,25 +454,20 @@ func (a *Authenticate) userInfo(w http.ResponseWriter, r *http.Request) error {
s.ID = uuid.New().String()
}
isImpersonated := false
pbSession, err := session.Get(ctx, state.dataBrokerClient, s.ID)
if pbSession.GetImpersonateSessionId() != "" {
pbSession, err = session.Get(ctx, state.dataBrokerClient, pbSession.GetImpersonateSessionId())
isImpersonated = true
}
pbSession, isImpersonated, err := a.getCurrentSession(ctx)
if err != nil {
pbSession = &session.Session{
Id: s.ID,
}
}
pbUser, err := user.Get(ctx, state.dataBrokerClient, pbSession.GetUserId())
pbUser, err := a.getUser(ctx, pbSession.GetUserId())
if err != nil {
pbUser = &user.User{
Id: pbSession.GetUserId(),
}
}
pbDirectoryUser, err := directory.GetUser(ctx, state.dataBrokerClient, pbSession.GetUserId())
pbDirectoryUser, err := a.getDirectoryUser(ctx, pbSession.GetUserId())
if err != nil {
pbDirectoryUser = &directory.User{
Id: pbSession.GetUserId(),
@ -493,15 +491,29 @@ func (a *Authenticate) userInfo(w http.ResponseWriter, r *http.Request) error {
return fmt.Errorf("invalid signout url: %w", err)
}
webAuthnURL, err := a.getWebAuthnURL(r.URL.Query())
if err != nil {
return fmt.Errorf("invalid webauthn url: %w", err)
}
var deviceCredentials []*device.Credential
for _, id := range pbUser.GetDeviceCredentialIds() {
deviceCredentials = append(deviceCredentials, &device.Credential{
Id: id,
})
}
input := map[string]interface{}{
"IsImpersonated": isImpersonated,
"State": s, // local session state (cookie, header, etc)
"Session": pbSession, // current access, refresh, id token
"User": pbUser, // user details inferred from oidc id_token
"DeviceCredentials": deviceCredentials,
"DirectoryUser": pbDirectoryUser, // user details inferred from idp directory
"DirectoryGroups": groups, // user's groups inferred from idp directory
"csrfField": csrf.TemplateField(r),
"SignOutURL": signoutURL,
"WebAuthnURL": webAuthnURL,
}
return a.templates.ExecuteTemplate(w, "userInfo.html", input)
}
@ -614,3 +626,49 @@ func (a *Authenticate) getSignOutURL(r *http.Request) (*url.URL, error) {
}
return urlutil.NewSignedURL(a.state.Load().sharedKey, uri).Sign(), nil
}
func (a *Authenticate) getCurrentSession(ctx context.Context) (s *session.Session, isImpersonated bool, err error) {
client := a.state.Load().dataBrokerClient
sessionState, err := a.getSessionFromCtx(ctx)
if err != nil {
return nil, false, err
}
isImpersonated = false
s, err = session.Get(ctx, client, sessionState.ID)
if s.GetImpersonateSessionId() != "" {
s, err = session.Get(ctx, client, s.GetImpersonateSessionId())
isImpersonated = true
}
return s, isImpersonated, err
}
func (a *Authenticate) getUser(ctx context.Context, userID string) (*user.User, error) {
client := a.state.Load().dataBrokerClient
return user.Get(ctx, client, userID)
}
func (a *Authenticate) getDirectoryUser(ctx context.Context, userID string) (*directory.User, error) {
client := a.state.Load().dataBrokerClient
return directory.GetUser(ctx, client, userID)
}
func (a *Authenticate) getWebauthnState(ctx context.Context) (*webauthn.State, error) {
state := a.state.Load()
s, _, err := a.getCurrentSession(ctx)
if err != nil {
return nil, err
}
return &webauthn.State{
SharedKey: state.sharedKey,
Client: state.dataBrokerClient,
Session: s,
RelyingParty: state.webauthnRelyingParty,
}, nil
}

View file

@ -0,0 +1,409 @@
// Package webauthn contains handlers for the WebAuthn flow in authenticate.
package webauthn
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"net"
"net/http"
"github.com/google/uuid"
"github.com/pomerium/csrf"
"github.com/pomerium/webauthn"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/pomerium/pomerium/internal/frontend"
"github.com/pomerium/pomerium/internal/httputil"
"github.com/pomerium/pomerium/internal/middleware"
"github.com/pomerium/pomerium/internal/urlutil"
"github.com/pomerium/pomerium/pkg/grpc/databroker"
"github.com/pomerium/pomerium/pkg/grpc/device"
"github.com/pomerium/pomerium/pkg/grpc/session"
"github.com/pomerium/pomerium/pkg/grpc/user"
"github.com/pomerium/pomerium/pkg/webauthnutil"
)
const maxAuthenticateResponses = 5
var (
errMissingDeviceType = httputil.NewError(http.StatusBadRequest, errors.New("device_type is a required parameter"))
errMissingRedirectURI = httputil.NewError(http.StatusBadRequest, errors.New("pomerium_redirect_uri is a required parameter"))
)
// State is the state needed by the Handler to handle requests.
type State struct {
SharedKey []byte
Client databroker.DataBrokerServiceClient
Session *session.Session
RelyingParty *webauthn.RelyingParty
}
// A StateProvider provides state for the handler.
type StateProvider = func(context.Context) (*State, error)
// Handler is the WebAuthn device handler.
type Handler struct {
getState StateProvider
templates *template.Template
}
// New creates a new Handler.
func New(getState StateProvider) *Handler {
return &Handler{
getState: getState,
templates: template.Must(frontend.NewTemplates()),
}
}
// ServeHTTP serves the HTTP handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
httputil.HandlerFunc(h.handle).ServeHTTP(w, r)
}
func (h *Handler) handle(w http.ResponseWriter, r *http.Request) error {
s, err := h.getState(r.Context())
if err != nil {
return err
}
err = middleware.ValidateRequestURL(r, s.SharedKey)
if err != nil {
return err
}
switch {
case r.Method == "GET":
return h.handleView(w, r, s)
case r.FormValue("action") == "authenticate":
return h.handleAuthenticate(w, r, s)
case r.FormValue("action") == "register":
return h.handleRegister(w, r, s)
}
return httputil.NewError(http.StatusNotFound, errors.New(http.StatusText(http.StatusNotFound)))
}
func (h *Handler) handleAuthenticate(w http.ResponseWriter, r *http.Request, state *State) error {
ctx := r.Context()
deviceTypeParam := r.FormValue(urlutil.QueryDeviceType)
if deviceTypeParam == "" {
return errMissingDeviceType
}
redirectURIParam := r.FormValue(urlutil.QueryRedirectURI)
if redirectURIParam == "" {
return errMissingRedirectURI
}
responseParam := r.FormValue("authenticate_response")
var credential webauthn.PublicKeyAssertionCredential
err := json.Unmarshal([]byte(responseParam), &credential)
if err != nil {
return httputil.NewError(http.StatusBadRequest, errors.New("invalid authenticate response"))
}
credentialJSON, err := json.Marshal(credential)
if err != nil {
return err
}
// get the user information
u, err := user.Get(ctx, state.Client, state.Session.GetUserId())
if err != nil {
return err
}
// get the stored device type
deviceType, err := device.GetType(ctx, state.Client, deviceTypeParam)
if err != nil {
return err
}
// get the device credentials
knownDeviceCredentials, err := getKnownDeviceCredentials(ctx, state.Client, u.GetDeviceCredentialIds()...)
if err != nil {
return err
}
requestOptions, err := webauthnutil.GetRequestOptionsForCredential(
state.SharedKey,
deviceType,
knownDeviceCredentials,
&credential,
)
if err != nil {
return httputil.NewError(http.StatusBadRequest, fmt.Errorf("invalid register options: %w", err))
}
serverCredential, err := state.RelyingParty.VerifyAuthenticationCeremony(
ctx,
requestOptions,
&credential,
)
if err != nil {
return httputil.NewError(http.StatusBadRequest, fmt.Errorf("error verifying registration: %w", err))
}
// store the authenticate response
for _, deviceCredential := range knownDeviceCredentials {
webauthnCredential := deviceCredential.GetWebauthn()
if webauthnCredential == nil {
continue
}
if !bytes.Equal(webauthnCredential.Id, serverCredential.ID) {
continue
}
// add the response to the list and cap it, removing the oldest responses
webauthnCredential.AuthenticateResponse = append(webauthnCredential.AuthenticateResponse, credentialJSON)
for len(webauthnCredential.AuthenticateResponse) > maxAuthenticateResponses {
webauthnCredential.AuthenticateResponse = webauthnCredential.AuthenticateResponse[1:]
}
// store the updated device credential
err = device.PutCredential(ctx, state.Client, deviceCredential)
if err != nil {
return err
}
}
// save the session
state.Session.DeviceCredentialId = webauthnutil.GetDeviceCredentialID(serverCredential.ID)
_, err = session.Put(ctx, state.Client, state.Session)
if err != nil {
return err
}
// redirect
httputil.Redirect(w, r, redirectURIParam, http.StatusFound)
return nil
}
func (h *Handler) handleRegister(w http.ResponseWriter, r *http.Request, state *State) error {
ctx := r.Context()
deviceTypeParam := r.FormValue(urlutil.QueryDeviceType)
if deviceTypeParam == "" {
return errMissingDeviceType
}
redirectURIParam := r.FormValue(urlutil.QueryRedirectURI)
if redirectURIParam == "" {
return errMissingRedirectURI
}
responseParam := r.FormValue("register_response")
var credential webauthn.PublicKeyCreationCredential
err := json.Unmarshal([]byte(responseParam), &credential)
if err != nil {
return httputil.NewError(http.StatusBadRequest, errors.New("invalid register response"))
}
credentialJSON, err := json.Marshal(credential)
if err != nil {
return err
}
// get the user information
u, err := user.Get(ctx, state.Client, state.Session.GetUserId())
if err != nil {
return err
}
// get the stored device type
deviceType, err := webauthnutil.GetDeviceType(ctx, state.Client, deviceTypeParam)
if err != nil {
return err
}
creationOptions, err := webauthnutil.GetCreationOptionsForCredential(
state.SharedKey,
deviceType,
u,
&credential,
)
if err != nil {
return httputil.NewError(http.StatusBadRequest, fmt.Errorf("invalid register options: %w", err))
}
creationOptionsJSON, err := json.Marshal(creationOptions)
if err != nil {
return err
}
serverCredential, err := state.RelyingParty.VerifyRegistrationCeremony(
ctx,
creationOptions,
&credential,
)
if err != nil {
return httputil.NewError(http.StatusBadRequest, fmt.Errorf("error verifying registration: %w", err))
}
deviceEnrollment, err := getOrCreateDeviceEnrollment(ctx, r, state, u)
if err != nil {
return err
}
// save the credential
deviceCredential := &device.Credential{
Id: webauthnutil.GetDeviceCredentialID(serverCredential.ID),
TypeId: deviceType.GetId(),
EnrollmentId: deviceEnrollment.GetId(),
UserId: u.GetId(),
Specifier: &device.Credential_Webauthn{
Webauthn: &device.Credential_WebAuthn{
Id: serverCredential.ID,
PublicKey: serverCredential.PublicKey,
RegisterOptions: creationOptionsJSON,
RegisterResponse: credentialJSON,
},
},
}
err = device.PutCredential(ctx, state.Client, deviceCredential)
if err != nil {
return err
}
// save the user
u.DeviceCredentialIds = append(u.DeviceCredentialIds, deviceCredential.GetId())
_, err = user.Put(ctx, state.Client, u)
if err != nil {
return err
}
// save the session
state.Session.DeviceCredentialId = deviceCredential.GetId()
_, err = session.Put(ctx, state.Client, state.Session)
if err != nil {
return err
}
// redirect
httputil.Redirect(w, r, redirectURIParam, http.StatusFound)
return nil
}
func (h *Handler) handleView(w http.ResponseWriter, r *http.Request, state *State) error {
ctx := r.Context()
deviceTypeParam := r.FormValue(urlutil.QueryDeviceType)
if deviceTypeParam == "" {
return errMissingDeviceType
}
// get the user information
u, err := user.Get(ctx, state.Client, state.Session.GetUserId())
if err != nil {
return err
}
// get the device credentials
knownDeviceCredentials, err := getKnownDeviceCredentials(ctx, state.Client, u.GetDeviceCredentialIds()...)
if err != nil {
return err
}
// get the stored device type
deviceType, err := webauthnutil.GetDeviceType(ctx, state.Client, deviceTypeParam)
if err != nil {
return err
}
creationOptions := webauthnutil.GenerateCreationOptions(state.SharedKey, deviceType, u)
requestOptions := webauthnutil.GenerateRequestOptions(state.SharedKey, deviceType, knownDeviceCredentials)
var buf bytes.Buffer
err = h.templates.ExecuteTemplate(&buf, "webauthn.html", map[string]interface{}{
"csrfField": csrf.TemplateField(r),
"Data": map[string]interface{}{
"creationOptions": creationOptions,
"requestOptions": requestOptions,
},
"SelfURL": r.URL.String(),
})
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
_, err = io.Copy(w, &buf)
return err
}
func getKnownDeviceCredentials(
ctx context.Context,
client databroker.DataBrokerServiceClient,
deviceCredentialIDs ...string,
) ([]*device.Credential, error) {
var knownDeviceCredentials []*device.Credential
for _, deviceCredentialID := range deviceCredentialIDs {
deviceCredential, err := device.GetCredential(ctx, client, deviceCredentialID)
if status.Code(err) == codes.NotFound {
// ignore missing devices
continue
} else if err != nil {
return nil, httputil.NewError(http.StatusInternalServerError,
fmt.Errorf("error retrieving device credential: %w", err))
}
knownDeviceCredentials = append(knownDeviceCredentials, deviceCredential)
}
return knownDeviceCredentials, nil
}
func getOrCreateDeviceEnrollment(
ctx context.Context,
r *http.Request,
state *State,
u *user.User,
) (*device.Enrollment, error) {
var deviceEnrollment *device.Enrollment
enrollmentTokenParam := r.FormValue(urlutil.QueryEnrollmentToken)
if enrollmentTokenParam == "" {
// create a new enrollment
deviceEnrollment = &device.Enrollment{
Id: uuid.New().String(),
UserId: u.GetId(),
}
} else {
// use an existing enrollment
deviceEnrollmentID, err := webauthnutil.ParseAndVerifyEnrollmentToken(state.SharedKey, enrollmentTokenParam)
if err != nil {
return nil, httputil.NewError(http.StatusBadRequest, fmt.Errorf("invalid enrollment token: %w", err))
}
deviceEnrollment, err = device.GetEnrollment(ctx, state.Client, deviceEnrollmentID)
if err != nil {
return nil, err
}
if deviceEnrollment.GetUserId() != u.GetId() {
return nil, httputil.NewError(http.StatusForbidden, fmt.Errorf("invalid enrollment token: wrong user id"))
}
if deviceEnrollment.GetEnrolledAt().IsValid() {
return nil, httputil.NewError(http.StatusForbidden, fmt.Errorf("invalid enrollment token: already used for existing credential"))
}
}
deviceEnrollment.EnrolledAt = timestamppb.Now()
deviceEnrollment.UserAgent = r.UserAgent()
if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
deviceEnrollment.IpAddress = ip
}
err := device.PutEnrollment(ctx, state.Client, deviceEnrollment)
if err != nil {
return nil, err
}
return deviceEnrollment, nil
}

View file

@ -9,6 +9,7 @@ import (
"sync/atomic"
"github.com/go-jose/go-jose/v3"
"github.com/pomerium/webauthn"
"github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/encoding"
@ -23,6 +24,7 @@ import (
"github.com/pomerium/pomerium/pkg/grpc"
"github.com/pomerium/pomerium/pkg/grpc/databroker"
"github.com/pomerium/pomerium/pkg/grpc/directory"
"github.com/pomerium/pomerium/pkg/webauthnutil"
)
var outboundGRPCConnection = new(grpc.CachedOutboundGRPClientConn)
@ -52,6 +54,8 @@ type authenticateState struct {
dataBrokerClient databroker.DataBrokerServiceClient
directoryClient directory.DirectoryServiceClient
webauthnRelyingParty *webauthn.RelyingParty
}
func newAuthenticateState() *authenticateState {
@ -158,6 +162,11 @@ func newAuthenticateStateFromConfig(cfg *config.Config) (*authenticateState, err
state.dataBrokerClient = databroker.NewDataBrokerServiceClient(dataBrokerConn)
state.directoryClient = directory.NewDirectoryServiceClient(dataBrokerConn)
state.webauthnRelyingParty = webauthn.NewRelyingParty(
authenticateURL.String(),
webauthnutil.NewCredentialStorage(state.dataBrokerClient),
)
return state, nil
}

View file

@ -177,6 +177,39 @@
</div>
</div>
</div>
<div class="category white box">
<div class="messages">
<div class="box-inner">
<div class="category-header clearfix">
<span class="category-title">Device Credentials</span>
</div>
</ul>
{{if .DeviceCredentials}}
<table>
<thead>
<tr>
<th>ID</th>
</tr>
</thead>
<tbody>
{{range .DeviceCredentials}}
<tr>
<td>{{.Id}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
No device credentials found!
{{end}}
</div>
<div class="category-link">
Register device with <a href="{{.WebAuthnURL}}">WebAuthn</a>.
</div>
</div>
</div>
<div id="footer">
<ul>
<li><a href="https://pomerium.com/">Home</a></li>

View file

@ -0,0 +1,67 @@
{{define "webauthn.html"}}
<!DOCTYPE html>
<html lang="en" charset="utf-8">
<head>
{{template "header.html"}}
<title>WebAuthn</title>
<script>
window.PomeriumData = {{.Data}};
</script>
</head>
<body>
<div class="inner">
<div class="header clearfix">
<div class="heading"></div>
</div>
<div class="content">
<div class="white box">
<div class="largestatus">
<div class="title-wrapper">
<span class="title">WebAuthn Registration</span>
</div>
</div>
</div>
<div class="category white box webauthn">
<div class="messages">
<div class="box-inner">
<form action="{{.SelfURL}}" method="post">
{{.csrfField}}
<input type="hidden" name="action" value="register" />
<input
type="hidden"
id="register_response"
name="register_response"
/>
<input
class="button"
type="submit"
id="register_button"
value="Register New Device"
/>
</form>
<form action="{{.SelfURL}}" method="post">
{{.csrfField}}
<input type="hidden" name="action" value="authenticate" />
<input
type="hidden"
id="authenticate_response"
name="authenticate_response"
/>
<input
class="button"
type="submit"
id="authenticate_button"
value="Authenticate Existing Device"
/>
</form>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/.pomerium/assets/js/webauthn.mjs"></script>
</body>
</html>
{{end}}

View file

@ -0,0 +1,92 @@
// The MIT License (MIT)
// Copyright (c) 2020 Blake Embrey (hello@blakeembrey.com)
// Copyright (c) 2012 Niklas von Hertzen (https://github.com/niklasvh/base64-arraybuffer)
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
/**
* Original source: https://github.com/niklasvh/base64-arraybuffer.
*/
const base64Chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const base64UrlChars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
const base64Lookup = new Uint8Array(256);
for (let i = 0; i < base64Chars.length; i++) {
base64Lookup[base64Chars.charCodeAt(i)] = i;
}
// Support base64url.
base64Lookup[45 /* - */] = 62;
base64Lookup[95 /* _ */] = 63;
/**
* Encode an `ArrayBuffer` to base64.
*/
function encode(buffer, chars = base64Chars, padding = "=") {
const bytes = new Uint8Array(buffer);
const length = bytes.length;
let base64 = "";
for (let i = 0; i < length; i += 3) {
base64 += chars[bytes[i] >> 2];
base64 += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)];
base64 += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)];
base64 += chars[bytes[i + 2] & 63];
}
if (length % 3 === 2) {
base64 = base64.slice(0, base64.length - 1) + padding;
} else if (length % 3 === 1) {
base64 = base64.slice(0, base64.length - 2) + padding + padding;
}
return base64;
}
/**
* Encode using the base64url variant.
*/
function encodeUrl(buffer) {
return encode(buffer, base64UrlChars, "");
}
/**
* Decode a base64 encoded string.
*/
function decode(base64, lookup = base64Lookup) {
const length = base64.length;
let bufferLength = Math.floor(base64.length * 0.75);
let p = 0;
if (base64[length - 1] === "=") {
bufferLength--;
if (base64[length - 2] === "=") {
bufferLength--;
}
}
const bytes = new Uint8Array(bufferLength);
for (let i = 0; i < length; i += 4) {
const encoded1 = lookup[base64.charCodeAt(i)];
const encoded2 = lookup[base64.charCodeAt(i + 1)];
const encoded3 = lookup[base64.charCodeAt(i + 2)];
const encoded4 = lookup[base64.charCodeAt(i + 3)];
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
}
return bytes;
}
export { encode, encodeUrl, decode };

View file

@ -0,0 +1,119 @@
import {
encodeUrl as base64encode,
decode as base64decode,
} from "./base64.mjs";
function decode(raw) {
return base64decode(raw);
}
function encode(raw) {
return base64encode(raw);
}
async function authenticate(requestOptions) {
const credential = await navigator.credentials.get({
publicKey: {
allowCredentials: requestOptions.allowCredentials.map((c) => ({
type: c.type,
id: decode(c.id),
})),
challenge: decode(requestOptions.challenge),
timeout: requestOptions.timeout,
userVerification: requestOptions.userVerification,
},
});
const inputEl = document.getElementById("authenticate_response");
inputEl.value = JSON.stringify({
id: credential.id,
type: credential.type,
rawId: encode(credential.rawId),
response: {
authenticatorData: encode(credential.response.authenticatorData),
clientDataJSON: encode(credential.response.clientDataJSON),
signature: encode(credential.response.signature),
userHandle: encode(credential.response.userHandle),
},
});
inputEl.parentElement.submit();
}
async function register(creationOptions) {
const credential = await navigator.credentials.create({
publicKey: {
attestation: creationOptions.attestation || undefined,
authenticatorSelection: {
authenticatorAttachment:
creationOptions.authenticatorSelection.authenticatorAttachment ||
undefined,
requireResidentKey:
creationOptions.authenticatorSelection.requireResidentKey ||
undefined,
residentKey: creationOptions.authenticatorSelection.residentKey,
userVerification:
creationOptions.authenticatorSelection.userVerification || undefined,
},
challenge: decode(creationOptions.challenge),
pubKeyCredParams: creationOptions.pubKeyCredParams.map((p) => ({
type: p.type,
alg: p.alg,
})),
rp: {
name: creationOptions.rp.name,
},
timeout: creationOptions.timeout,
user: {
id: decode(creationOptions.user.id),
name: creationOptions.user.name,
displayName: creationOptions.user.displayName,
},
},
});
const inputEl = document.getElementById("register_response");
inputEl.value = JSON.stringify({
id: credential.id,
type: credential.type,
rawId: encode(credential.rawId),
response: {
attestationObject: encode(credential.response.attestationObject),
clientDataJSON: encode(credential.response.clientDataJSON),
},
});
inputEl.parentElement.submit();
}
function init() {
if (!("PomeriumData" in window)) {
return;
}
const requestOptions = window.PomeriumData.requestOptions;
const authenticateButton = document.getElementById("authenticate_button");
if (authenticateButton) {
if (
requestOptions.allowCredentials &&
requestOptions.allowCredentials.length > 0
) {
authenticateButton.addEventListener("click", function(evt) {
evt.preventDefault();
authenticate(requestOptions);
});
} else {
authenticateButton.addEventListener("click", function(evt) {
evt.preventDefault();
});
authenticateButton.setAttribute("disabled", "DISABLED");
}
}
const creationOptions = window.PomeriumData.creationOptions;
const registerButton = document.getElementById("register_button");
if (registerButton) {
registerButton.addEventListener("click", function(evt) {
evt.preventDefault();
register(creationOptions);
});
}
}
init();

View file

@ -476,3 +476,17 @@ a.button {
text-transform: none;
transition: box-shadow 150ms ease-in-out;
}
button:disabled, input:disabled {
cursor: default;
background:#cccccc;
color: #838383;
}
.webauthn .box-inner {
text-align: center;
}
.webauthn form {
display: inline-block;
}

File diff suppressed because one or more lines are too long

View file

@ -31,7 +31,7 @@ const (
// by default includes profile photo exceptions for supported identity providers.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src
var HeadersContentSecurityPolicy = map[string]string{
"Content-Security-Policy": "default-src 'none'; style-src 'self' data:; img-src * data:;",
"Content-Security-Policy": "default-src 'none'; style-src 'self' data:; img-src * data:; script-src 'self' 'unsafe-inline'",
"Referrer-Policy": "Same-origin",
}

View file

@ -5,6 +5,8 @@ package urlutil
// conjunction with a HMAC to ensure authenticity.
const (
QueryCallbackURI = "pomerium_callback_uri"
QueryDeviceType = "pomerium_device_type"
QueryEnrollmentToken = "pomerium_enrollment_token" //nolint
QueryIsProgrammatic = "pomerium_programmatic"
QueryForwardAuth = "pomerium_forward_auth"
QueryPomeriumJWT = "pomerium_jwt"