mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: secure and cross-domain subdomain-based proxying (#4136)
Co-authored-by: Kyle Carberry <kyle@carberry.com>
This commit is contained in:
co-authored by
Kyle Carberry
parent
80b45f1aa1
commit
6deef06ad2
+421
-27
@@ -1,15 +1,22 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"golang.org/x/xerrors"
|
||||
jose "gopkg.in/square/go-jose.v2"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
@@ -20,6 +27,22 @@ import (
|
||||
"github.com/coder/coder/site"
|
||||
)
|
||||
|
||||
const (
|
||||
// This needs to be a super unique query parameter because we don't want to
|
||||
// conflict with query parameters that users may use.
|
||||
// TODO: this will make dogfooding harder so come up with a more unique
|
||||
// solution
|
||||
//nolint:gosec
|
||||
subdomainProxyAPIKeyParam = "coder_application_connect_api_key_35e783"
|
||||
redirectURIQueryParam = "redirect_uri"
|
||||
)
|
||||
|
||||
func (api *API) appHost(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.GetAppHostResponse{
|
||||
Host: api.AppHostname,
|
||||
})
|
||||
}
|
||||
|
||||
// workspaceAppsProxyPath proxies requests to a workspace application
|
||||
// through a relative URL path.
|
||||
func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -50,18 +73,61 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
|
||||
}, rw, r)
|
||||
}
|
||||
|
||||
// handleSubdomainApplications handles subdomain-based application proxy
|
||||
// requests (aka. DevURLs in Coder V1).
|
||||
//
|
||||
// There are a lot of paths here:
|
||||
// 1. If api.AppHostname is not set then we pass on.
|
||||
// 2. If we can't read the request hostname then we return a 400.
|
||||
// 3. If the request hostname matches api.AccessURL then we pass on.
|
||||
// 5. We split the subdomain into the subdomain and the "rest". If there are no
|
||||
// periods in the hostname then we pass on.
|
||||
// 5. We parse the subdomain into a httpapi.ApplicationURL struct. If we
|
||||
// encounter an error:
|
||||
// a. If the "rest" does not match api.AppHostname then we pass on;
|
||||
// b. Otherwise, we return a 400.
|
||||
// 6. Finally, we verify that the "rest" matches api.AppHostname, else we
|
||||
// return a 404.
|
||||
//
|
||||
// Rationales for each of the above steps:
|
||||
// 1. We pass on if api.AppHostname is not set to avoid returning any errors if
|
||||
// `--app-hostname` is not configured.
|
||||
// 2. Every request should have a valid Host header anyways.
|
||||
// 3. We pass on if the request hostname matches api.AccessURL so we can
|
||||
// support having the access URL be at the same level as the application
|
||||
// base hostname.
|
||||
// 4. We pass on if there are no periods in the hostname as application URLs
|
||||
// must be a subdomain of a hostname, which implies there must be at least
|
||||
// one period.
|
||||
// 5. a. If the request subdomain is not a valid application URL, and the
|
||||
// "rest" does not match api.AppHostname, then it is very unlikely that
|
||||
// the request was intended for this handler. We pass on.
|
||||
// b. If the request subdomain is not a valid application URL, but the
|
||||
// "rest" matches api.AppHostname, then we return a 400 because the
|
||||
// request is probably a typo or something.
|
||||
// 6. We finally verify that the "rest" matches api.AppHostname for security
|
||||
// purposes regarding re-authentication and application proxy session
|
||||
// tokens.
|
||||
func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Step 1: Pass on if subdomain-based application proxying is not
|
||||
// configured.
|
||||
if api.AppHostname == "" {
|
||||
next.ServeHTTP(rw, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: Get the request Host.
|
||||
host := httpapi.RequestHost(r)
|
||||
if host == "" {
|
||||
if r.URL.Path == "/derp" {
|
||||
// The /derp endpoint is used by wireguard clients to tunnel
|
||||
// through coderd. For some reason these requests don't set
|
||||
// a Host header properly sometimes (no idea how), which
|
||||
// causes this path to get hit.
|
||||
// a Host header properly sometimes in tests (no idea how),
|
||||
// which causes this path to get hit.
|
||||
next.ServeHTTP(rw, r)
|
||||
return
|
||||
}
|
||||
@@ -72,15 +138,9 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
|
||||
return
|
||||
}
|
||||
|
||||
app, err := httpapi.ParseSubdomainAppURL(host)
|
||||
if err != nil {
|
||||
// Subdomain is not a valid application url. Pass through to the
|
||||
// rest of the app.
|
||||
// TODO: @emyrk we should probably catch invalid subdomains. Meaning
|
||||
// an invalid application should not route to the coderd.
|
||||
// To do this we would need to know the list of valid access urls
|
||||
// though?
|
||||
next.ServeHTTP(rw, r)
|
||||
// Steps 3-6: Parse application from subdomain.
|
||||
app, ok := api.parseWorkspaceApplicationHostname(rw, r, next, host)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -95,6 +155,12 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
|
||||
workspace := httpmw.WorkspaceParam(r)
|
||||
agent := httpmw.WorkspaceAgentParam(r)
|
||||
|
||||
// Verify application auth. This function will redirect or
|
||||
// return an error page if the user doesn't have permission.
|
||||
if !api.verifyWorkspaceApplicationAuth(rw, r, workspace, host) {
|
||||
return
|
||||
}
|
||||
|
||||
api.proxyWorkspaceApplication(proxyApplication{
|
||||
Workspace: workspace,
|
||||
Agent: agent,
|
||||
@@ -108,6 +174,235 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *http.Request, next http.Handler, host string) (httpapi.ApplicationURL, bool) {
|
||||
ctx := r.Context()
|
||||
// Check if the hostname matches the access URL. If it does, the
|
||||
// user was definitely trying to connect to the dashboard/API.
|
||||
if httpapi.HostnamesMatch(api.AccessURL.Hostname(), host) {
|
||||
next.ServeHTTP(rw, r)
|
||||
return httpapi.ApplicationURL{}, false
|
||||
}
|
||||
|
||||
// Split the subdomain so we can parse the application details and
|
||||
// verify it matches the configured app hostname later.
|
||||
subdomain, rest := httpapi.SplitSubdomain(host)
|
||||
if rest == "" {
|
||||
// If there are no periods in the hostname, then it can't be a
|
||||
// valid application URL.
|
||||
next.ServeHTTP(rw, r)
|
||||
return httpapi.ApplicationURL{}, false
|
||||
}
|
||||
matchingBaseHostname := httpapi.HostnamesMatch(api.AppHostname, rest)
|
||||
|
||||
// Parse the application URL from the subdomain.
|
||||
app, err := httpapi.ParseSubdomainAppURL(subdomain)
|
||||
if err != nil {
|
||||
// If it isn't a valid app URL and the base domain doesn't match
|
||||
// the configured app hostname, this request was probably
|
||||
// destined for the dashboard/API router.
|
||||
if !matchingBaseHostname {
|
||||
next.ServeHTTP(rw, r)
|
||||
return httpapi.ApplicationURL{}, false
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Could not parse subdomain application URL.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return httpapi.ApplicationURL{}, false
|
||||
}
|
||||
|
||||
// At this point we've verified that the subdomain looks like a
|
||||
// valid application URL, so the base hostname should match the
|
||||
// configured app hostname.
|
||||
if !matchingBaseHostname {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "The server does not accept application requests on this hostname.",
|
||||
})
|
||||
return httpapi.ApplicationURL{}, false
|
||||
}
|
||||
|
||||
return app, true
|
||||
}
|
||||
|
||||
// verifyWorkspaceApplicationAuth checks that the request is authorized to
|
||||
// access the given application. If the user does not have a app session key,
|
||||
// they will be redirected to the route below. If the user does have a session
|
||||
// key but insufficient permissions a static error page will be rendered.
|
||||
func (api *API) verifyWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, workspace database.Workspace, host string) bool {
|
||||
ctx := r.Context()
|
||||
_, ok := httpmw.APIKeyOptional(r)
|
||||
if ok {
|
||||
if !api.Authorize(r, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) {
|
||||
// TODO: This should be a static error page.
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return false
|
||||
}
|
||||
|
||||
// Request should be all good to go!
|
||||
return true
|
||||
}
|
||||
|
||||
// If the request has the special query param then we need to set a cookie
|
||||
// and strip that query parameter.
|
||||
if encryptedAPIKey := r.URL.Query().Get(subdomainProxyAPIKeyParam); encryptedAPIKey != "" {
|
||||
// Exchange the encoded API key for a real one.
|
||||
_, apiKey, err := decryptAPIKey(r.Context(), api.Database, encryptedAPIKey)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Could not decrypt API key. Please remove the query parameter and try again.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Set the app cookie for all subdomains of api.AppHostname. This cookie
|
||||
// is handled properly by the ExtractAPIKey middleware.
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: httpmw.DevURLSessionTokenCookie,
|
||||
Value: apiKey,
|
||||
Domain: "." + api.AppHostname,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: api.SecureAuthCookie,
|
||||
})
|
||||
|
||||
// Strip the query parameter.
|
||||
path := r.URL.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
q := r.URL.Query()
|
||||
q.Del(subdomainProxyAPIKeyParam)
|
||||
rawQuery := q.Encode()
|
||||
if rawQuery != "" {
|
||||
path += "?" + q.Encode()
|
||||
}
|
||||
|
||||
http.Redirect(rw, r, path, http.StatusTemporaryRedirect)
|
||||
return false
|
||||
}
|
||||
|
||||
// If the user doesn't have a session key, redirect them to the API endpoint
|
||||
// for application auth.
|
||||
redirectURI := *r.URL
|
||||
redirectURI.Scheme = api.AccessURL.Scheme
|
||||
redirectURI.Host = host
|
||||
|
||||
u := *api.AccessURL
|
||||
u.Path = "/api/v2/applications/auth-redirect"
|
||||
q := u.Query()
|
||||
q.Add(redirectURIQueryParam, redirectURI.String())
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
|
||||
return false
|
||||
}
|
||||
|
||||
// workspaceApplicationAuth is an endpoint on the main router that handles
|
||||
// redirects from the subdomain handler.
|
||||
func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if api.AppHostname == "" {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "The server does not accept subdomain-based application requests.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
apiKey := httpmw.APIKey(r)
|
||||
if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceAPIKey.WithOwner(apiKey.UserID.String())) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the redirect URI from the query parameters and parse it.
|
||||
redirectURI := r.URL.Query().Get(redirectURIQueryParam)
|
||||
if redirectURI == "" {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Missing redirect_uri query parameter.",
|
||||
})
|
||||
return
|
||||
}
|
||||
u, err := url.Parse(redirectURI)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid redirect_uri query parameter.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// Force the redirect URI to use the same scheme as the access URL for
|
||||
// security purposes.
|
||||
u.Scheme = api.AccessURL.Scheme
|
||||
|
||||
// Ensure that the redirect URI is a subdomain of api.AppHostname and is a
|
||||
// valid app subdomain.
|
||||
subdomain, rest := httpapi.SplitSubdomain(u.Hostname())
|
||||
if !httpapi.HostnamesMatch(api.AppHostname, rest) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "The redirect_uri query parameter must be a valid app subdomain.",
|
||||
})
|
||||
return
|
||||
}
|
||||
_, err = httpapi.ParseSubdomainAppURL(subdomain)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "The redirect_uri query parameter must be a valid app subdomain.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create the application_connect-scoped API key with the same lifetime as
|
||||
// the current session (defaulting to 1 day, capped to 1 week).
|
||||
exp := apiKey.ExpiresAt
|
||||
if exp.IsZero() {
|
||||
exp = database.Now().Add(time.Hour * 24)
|
||||
}
|
||||
if time.Until(exp) > time.Hour*24*7 {
|
||||
exp = database.Now().Add(time.Hour * 24 * 7)
|
||||
}
|
||||
lifetime := apiKey.LifetimeSeconds
|
||||
if lifetime > int64((time.Hour * 24 * 7).Seconds()) {
|
||||
lifetime = int64((time.Hour * 24 * 7).Seconds())
|
||||
}
|
||||
cookie, err := api.createAPIKey(ctx, createAPIKeyParams{
|
||||
UserID: apiKey.UserID,
|
||||
LoginType: database.LoginTypePassword,
|
||||
ExpiresAt: exp,
|
||||
LifetimeSeconds: lifetime,
|
||||
Scope: database.APIKeyScopeApplicationConnect,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to create API key.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Encrypt the API key.
|
||||
encryptedAPIKey, err := encryptAPIKey(encryptedAPIKeyPayload{
|
||||
APIKey: cookie.Value,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to encrypt API key.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to the redirect URI with the encrypted API key in the query
|
||||
// parameters.
|
||||
q := u.Query()
|
||||
q.Set(subdomainProxyAPIKeyParam, encryptedAPIKey)
|
||||
u.RawQuery = q.Encode()
|
||||
http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// proxyApplication are the required fields to proxy a workspace application.
|
||||
type proxyApplication struct {
|
||||
Workspace database.Workspace
|
||||
@@ -235,22 +530,121 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
|
||||
proxy.ServeHTTP(rw, r)
|
||||
}
|
||||
|
||||
// applicationCookie is a helper function to copy the auth cookie to also
|
||||
// support subdomains. Until we support creating authentication cookies that can
|
||||
// only do application authentication, we will just reuse the original token.
|
||||
// This code should be temporary and be replaced with something that creates
|
||||
// a unique session_token.
|
||||
type encryptedAPIKeyPayload struct {
|
||||
APIKey string `json:"api_key"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// encryptAPIKey encrypts an API key with it's own hashed secret. This is used
|
||||
// for smuggling (application_connect scoped) API keys securely to app
|
||||
// hostnames.
|
||||
//
|
||||
// Returns nil if the access URL isn't a hostname.
|
||||
func (api *API) applicationCookie(authCookie *http.Cookie) *http.Cookie {
|
||||
if net.ParseIP(api.AccessURL.Hostname()) != nil {
|
||||
return nil
|
||||
// We encrypt API keys when smuggling them in query parameters to avoid them
|
||||
// getting accidentally logged in access logs or stored in browser history.
|
||||
func encryptAPIKey(data encryptedAPIKeyPayload) (string, error) {
|
||||
if data.APIKey == "" {
|
||||
return "", xerrors.New("API key is empty")
|
||||
}
|
||||
if data.ExpiresAt.IsZero() {
|
||||
// Very short expiry as these keys are only used once as part of an
|
||||
// automatic redirection flow.
|
||||
data.ExpiresAt = database.Now().Add(time.Minute)
|
||||
}
|
||||
|
||||
appCookie := *authCookie
|
||||
// We only support setting this cookie on the access URL subdomains. This is
|
||||
// to ensure we don't accidentally leak the auth cookie to subdomains on
|
||||
// another hostname.
|
||||
appCookie.Domain = "." + api.AccessURL.Hostname()
|
||||
return &appCookie
|
||||
payload, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
// We use the hashed key secret as the encryption key. The hashed secret is
|
||||
// stored in the API keys table. The HashedSecret is NEVER returned from the
|
||||
// API.
|
||||
//
|
||||
// We chose to use the key secret as the private key for encryption instead
|
||||
// of a shared key for a few reasons:
|
||||
// 1. A single private key used to encrypt every API key would also be
|
||||
// stored in the database, which means that the risk factor is similar.
|
||||
// 2. The secret essentially rotates for each key (for free!), since each
|
||||
// key has a different secret. This means that if someone acquires an
|
||||
// old database dump they can't decrypt new API keys.
|
||||
// 3. These tokens are scoped only for application_connect access.
|
||||
keyID, keySecret, err := httpmw.SplitAPIToken(data.APIKey)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("split API key: %w", err)
|
||||
}
|
||||
// SHA256 the key secret so it matches the hashed secret in the database.
|
||||
// The key length doesn't matter to the jose.Encrypter.
|
||||
privateKey := sha256.Sum256([]byte(keySecret))
|
||||
|
||||
// JWEs seem to apply a nonce themselves.
|
||||
encrypter, err := jose.NewEncrypter(
|
||||
jose.A256GCM,
|
||||
jose.Recipient{
|
||||
Algorithm: jose.A256GCMKW,
|
||||
KeyID: keyID,
|
||||
Key: privateKey[:],
|
||||
},
|
||||
&jose.EncrypterOptions{
|
||||
Compression: jose.DEFLATE,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("initializer jose encrypter: %w", err)
|
||||
}
|
||||
encryptedObject, err := encrypter.Encrypt(payload)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("encrypt jwe: %w", err)
|
||||
}
|
||||
|
||||
encrypted := encryptedObject.FullSerialize()
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(encrypted)), nil
|
||||
}
|
||||
|
||||
// decryptAPIKey undoes encryptAPIKey and is used in the subdomain app handler.
|
||||
func decryptAPIKey(ctx context.Context, db database.Store, encryptedAPIKey string) (database.APIKey, string, error) {
|
||||
encrypted, err := base64.RawURLEncoding.DecodeString(encryptedAPIKey)
|
||||
if err != nil {
|
||||
return database.APIKey{}, "", xerrors.Errorf("base64 decode encrypted API key: %w", err)
|
||||
}
|
||||
|
||||
object, err := jose.ParseEncrypted(string(encrypted))
|
||||
if err != nil {
|
||||
return database.APIKey{}, "", xerrors.Errorf("parse encrypted API key: %w", err)
|
||||
}
|
||||
|
||||
// Lookup the API key so we can decrypt it.
|
||||
keyID := object.Header.KeyID
|
||||
key, err := db.GetAPIKeyByID(ctx, keyID)
|
||||
if err != nil {
|
||||
return database.APIKey{}, "", xerrors.Errorf("get API key by key ID: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt using the hashed secret.
|
||||
decrypted, err := object.Decrypt(key.HashedSecret)
|
||||
if err != nil {
|
||||
return database.APIKey{}, "", xerrors.Errorf("decrypt API key: %w", err)
|
||||
}
|
||||
|
||||
// Unmarshal the payload.
|
||||
var payload encryptedAPIKeyPayload
|
||||
if err := json.Unmarshal(decrypted, &payload); err != nil {
|
||||
return database.APIKey{}, "", xerrors.Errorf("unmarshal decrypted payload: %w", err)
|
||||
}
|
||||
|
||||
// Validate expiry.
|
||||
if payload.ExpiresAt.Before(database.Now()) {
|
||||
return database.APIKey{}, "", xerrors.New("encrypted API key expired")
|
||||
}
|
||||
|
||||
// Validate that the key matches the one we got from the DB.
|
||||
gotKeyID, gotKeySecret, err := httpmw.SplitAPIToken(payload.APIKey)
|
||||
if err != nil {
|
||||
return database.APIKey{}, "", xerrors.Errorf("split API key: %w", err)
|
||||
}
|
||||
gotHashedSecret := sha256.Sum256([]byte(gotKeySecret))
|
||||
if gotKeyID != key.ID || !bytes.Equal(key.HashedSecret, gotHashedSecret[:]) {
|
||||
return database.APIKey{}, "", xerrors.New("encrypted API key does not match key in database")
|
||||
}
|
||||
|
||||
return key, payload.APIKey, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user