mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
`rbac.IsExternalScope` accepts `all` and `application_connect`, neither
of which is an `api_key_scope` enum member. The plural `Scopes` field
stored the name as given, so `{"scopes":["all"]}` passed validation and
then 500'd inside `apikey.Generate` with `invalid API key scope: "all"`.
Reachable from `coder tokens create --scope all`, from the spellings
`docs/admin/users/sessions-tokens.md` taught, and from the `codersdk`
constants exported for exactly this purpose.
- Canonicalize inside `apikey.Generate`, in the loop that already
validates each name. One statement covers every caller and all three
input paths: plural `Scopes`, deprecated singular `Scope`, and the
default. Replaces two open-coded switches and the handler's own
per-element copy, so one place decides the stored spelling and the
handler only decides what may be requested.
- Deduplicate in the same pass. An alias and its canonical spelling are
two names going in and one name in the column.
- `ExternalScopeNames()` lists every name `IsExternalScope` accepts,
instead of dropping catalog entries that fail to parse. A curated entry
that cannot be stored used to pass every test and 500 at runtime; it now
fails two.
- Split the 400. A misspelled name and an internal `api_key_scope`
member need different words, since no re-spelling makes the second
requestable. Both rejection sites share one helper that names the case
and links the docs.
- Add reject-path tests for `not_a_real_scope` and `debug_info:read`.
The second is a valid enum member the rbac catalog treats as internal,
so before this both handler guards could be deleted with the suite still
green. A further case pins that plural `Scopes` wins when a caller sets
both fields.
- `TestExternalScopesAreStorable` pins the class rather than the two
known instances: every public rbac scope name must be storable.
`coderd/rbac` cannot assert this itself, since `database` imports `rbac`
and not the reverse.
- Docs use the canonical spellings, link the `codersdk.APIKeyScope`
schema, and say which scopes a token cannot request.
Egress is unchanged: `convertAPIKey` still derives a legacy singular
name on the way out. `ExternalScopeNames()` returns the same 57 names as
before, and `codersdk/apikey_scopes_gen.go` regenerates identically.
`rbac.CanonicalScopeName` merged with #28167, so this applies to `main`
and reviews on its own. The OAuth2 provider ignores scopes entirely
today (`authorize.go:237`, `tokens.go:377`, `tokens.go:520`), so it is
unaffected until those TODOs resolve.
Deferred from review: PLAT-528, PLAT-529, PLAT-530, and PLAT-532.
PLAT-532 covers `{"scopes":[]}` and `{"allow_list":[]}` defaulting open,
which predates this PR.
---------
Co-authored-by: McKayla はな <mckayla@hey.com>
607 lines
19 KiB
Go
607 lines
19 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/apikey"
|
|
"github.com/coder/coder/v2/coderd/audit"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
"github.com/coder/coder/v2/coderd/telemetry"
|
|
"github.com/coder/coder/v2/coderd/util/namesgenerator"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// scopeDocsURL points at the scopes a token may request. The api_key_scope enum
|
|
// table in the API reference is a superset: it lists internal scopes too.
|
|
const scopeDocsURL = "https://coder.com/docs/admin/users/sessions-tokens#api-key-scopes"
|
|
|
|
// writeUnrequestableScope answers 400 for a scope name a token may not carry,
|
|
// telling a name that is no scope at all apart from a real api_key_scope member
|
|
// that is internal to Coder.
|
|
func writeUnrequestableScope(ctx context.Context, rw http.ResponseWriter, name rbac.ScopeName) {
|
|
detail := fmt.Sprintf("unknown API key scope: %q. See %s for the scopes a token may request.", name, scopeDocsURL)
|
|
if database.APIKeyScope(name).Valid() {
|
|
detail = fmt.Sprintf("API key scope %q is internal and cannot be requested by a token. See %s for the scopes a token may request.", name, scopeDocsURL)
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Failed to create API key.",
|
|
Detail: detail,
|
|
})
|
|
}
|
|
|
|
// Creates a new token API key with the given scope and lifetime.
|
|
//
|
|
// @Summary Create token API key
|
|
// @ID create-token-api-key
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Param request body codersdk.CreateTokenRequest true "Create token request"
|
|
// @Success 201 {object} codersdk.GenerateAPIKeyResponse
|
|
// @Router /api/v2/users/{user}/keys/tokens [post]
|
|
func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
user = httpmw.UserParam(r)
|
|
auditor = api.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.APIKey](rw, &audit.RequestParams{
|
|
Audit: *auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionCreate,
|
|
})
|
|
)
|
|
aReq.Old = database.APIKey{}
|
|
defer commitAudit()
|
|
|
|
var createToken codersdk.CreateTokenRequest
|
|
if !httpapi.Read(ctx, rw, r, &createToken) {
|
|
return
|
|
}
|
|
|
|
// TODO(Cian): System users technically just have the 'member' role
|
|
// and we don't want to disallow all members from creating API keys.
|
|
if user.IsSystem {
|
|
api.Logger.Warn(ctx, "disallowed creating api key for system user", slog.F("user_id", user.ID))
|
|
httpapi.Forbidden(rw)
|
|
return
|
|
}
|
|
|
|
// This handler decides only which names may be requested. Rewriting an
|
|
// accepted alias to the spelling the enum stores belongs to apikey.Generate,
|
|
// which every caller goes through. The plural field wins when both are set.
|
|
scopes := database.APIKeyScopes{database.ApiKeyScopeCoderAll}
|
|
if len(createToken.Scopes) > 0 {
|
|
scopes = make(database.APIKeyScopes, 0, len(createToken.Scopes))
|
|
for _, s := range createToken.Scopes {
|
|
name := rbac.ScopeName(s)
|
|
if !rbac.IsExternalScope(name) {
|
|
writeUnrequestableScope(ctx, rw, name)
|
|
return
|
|
}
|
|
scopes = append(scopes, database.APIKeyScope(name))
|
|
}
|
|
} else if string(createToken.Scope) != "" {
|
|
name := rbac.ScopeName(createToken.Scope)
|
|
if !rbac.IsExternalScope(name) {
|
|
writeUnrequestableScope(ctx, rw, name)
|
|
return
|
|
}
|
|
scopes = database.APIKeyScopes{database.APIKeyScope(name)}
|
|
}
|
|
|
|
tokenName := namesgenerator.NameDigitWith("_")
|
|
|
|
if len(createToken.TokenName) != 0 {
|
|
tokenName = createToken.TokenName
|
|
}
|
|
|
|
params := apikey.CreateParams{
|
|
UserID: user.ID,
|
|
LoginType: database.LoginTypeToken,
|
|
DefaultLifetime: api.DeploymentValues.Sessions.DefaultTokenDuration.Value(),
|
|
Scopes: scopes,
|
|
TokenName: tokenName,
|
|
}
|
|
|
|
if len(createToken.AllowList) > 0 {
|
|
rbacAllowListElements := make([]rbac.AllowListElement, 0, len(createToken.AllowList))
|
|
for _, t := range createToken.AllowList {
|
|
entry, err := rbac.NewAllowListElement(string(t.Type), t.ID)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Failed to create API key.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
rbacAllowListElements = append(rbacAllowListElements, entry)
|
|
}
|
|
|
|
rbacAllowList, err := rbac.NormalizeAllowList(rbacAllowListElements)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Failed to create API key.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
dbAllowList := make(database.AllowList, 0, len(rbacAllowList))
|
|
for _, e := range rbacAllowList {
|
|
dbAllowList = append(dbAllowList, rbac.AllowListElement{Type: e.Type, ID: e.ID})
|
|
}
|
|
|
|
params.AllowList = dbAllowList
|
|
}
|
|
|
|
if createToken.Lifetime != 0 {
|
|
err := api.validateAPIKeyLifetime(ctx, user.ID, createToken.Lifetime)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Failed to validate create API key request.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
params.ExpiresAt = dbtime.Now().Add(createToken.Lifetime)
|
|
params.LifetimeSeconds = int64(createToken.Lifetime.Seconds())
|
|
}
|
|
|
|
cookie, key, err := api.createAPIKey(ctx, params)
|
|
if err != nil {
|
|
if database.IsUniqueViolation(err, database.UniqueIndexAPIKeyName) {
|
|
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
|
Message: fmt.Sprintf("A token with name %q already exists.", tokenName),
|
|
Validations: []codersdk.ValidationError{{
|
|
Field: "name",
|
|
Detail: "This value is already in use and should be unique.",
|
|
}},
|
|
})
|
|
return
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to create API key.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
aReq.New = *key
|
|
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.GenerateAPIKeyResponse{Key: cookie.Value})
|
|
}
|
|
|
|
// Creates a new session key, used for logging in via the CLI.
|
|
//
|
|
// @Summary Create new session key
|
|
// @ID create-new-session-key
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Success 201 {object} codersdk.GenerateAPIKeyResponse
|
|
// @Router /api/v2/users/{user}/keys [post]
|
|
func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
user = httpmw.UserParam(r)
|
|
auditor = api.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.APIKey](rw, &audit.RequestParams{
|
|
Audit: *auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionCreate,
|
|
})
|
|
)
|
|
aReq.Old = database.APIKey{}
|
|
defer commitAudit()
|
|
|
|
// TODO(Cian): System users technically just have the 'member' role
|
|
// and we don't want to disallow all members from creating API keys.
|
|
if user.IsSystem {
|
|
api.Logger.Warn(ctx, "disallowed creating api key for system user", slog.F("user_id", user.ID))
|
|
httpapi.Forbidden(rw)
|
|
return
|
|
}
|
|
|
|
cookie, key, err := api.createAPIKey(ctx, apikey.CreateParams{
|
|
UserID: user.ID,
|
|
DefaultLifetime: api.DeploymentValues.Sessions.DefaultTokenDuration.Value(),
|
|
LoginType: database.LoginTypePassword,
|
|
RemoteAddr: r.RemoteAddr,
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to create API key.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
aReq.New = *key
|
|
// We intentionally do not set the cookie on the response here.
|
|
// Setting the cookie will couple the browser session to the API
|
|
// key we return here, meaning logging out of the website would
|
|
// invalid your CLI key.
|
|
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.GenerateAPIKeyResponse{Key: cookie.Value})
|
|
}
|
|
|
|
// @Summary Get API key by ID
|
|
// @ID get-api-key-by-id
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Param keyid path string true "Key ID" format(string)
|
|
// @Success 200 {object} codersdk.APIKey
|
|
// @Router /api/v2/users/{user}/keys/{keyid} [get]
|
|
func (api *API) apiKeyByID(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
keyID := chi.URLParam(r, "keyid")
|
|
key, err := api.Database.GetAPIKeyByID(ctx, keyID)
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching API key.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, convertAPIKey(key))
|
|
}
|
|
|
|
// @Summary Get API key by token name
|
|
// @ID get-api-key-by-token-name
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Param keyname path string true "Key Name" format(string)
|
|
// @Success 200 {object} codersdk.APIKey
|
|
// @Router /api/v2/users/{user}/keys/tokens/{keyname} [get]
|
|
func (api *API) apiKeyByName(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
user = httpmw.UserParam(r)
|
|
tokenName = chi.URLParam(r, "keyname")
|
|
)
|
|
|
|
token, err := api.Database.GetAPIKeyByName(ctx, database.GetAPIKeyByNameParams{
|
|
TokenName: tokenName,
|
|
UserID: user.ID,
|
|
})
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching API key.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, convertAPIKey(token))
|
|
}
|
|
|
|
// @Summary Get user tokens
|
|
// @ID get-user-tokens
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Success 200 {array} codersdk.APIKey
|
|
// @Param include_expired query bool false "Include expired tokens in the list"
|
|
// @Router /api/v2/users/{user}/keys/tokens [get]
|
|
func (api *API) tokens(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
user = httpmw.UserParam(r)
|
|
keys []database.APIKey
|
|
err error
|
|
queryStr = r.URL.Query().Get("include_all")
|
|
includeAll, _ = strconv.ParseBool(queryStr)
|
|
expiredStr = r.URL.Query().Get("include_expired")
|
|
includeExpired, _ = strconv.ParseBool(expiredStr)
|
|
)
|
|
|
|
if includeAll {
|
|
// get tokens for all users
|
|
keys, err = api.Database.GetAPIKeysByLoginType(ctx, database.GetAPIKeysByLoginTypeParams{
|
|
LoginType: database.LoginTypeToken,
|
|
IncludeExpired: includeExpired,
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching API keys.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
} else {
|
|
// get user's tokens only
|
|
keys, err = api.Database.GetAPIKeysByUserID(ctx, database.GetAPIKeysByUserIDParams{LoginType: database.LoginTypeToken, UserID: user.ID, IncludeExpired: includeExpired})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching API keys.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
keys, err = AuthorizeFilter(api.HTTPAuth, r, policy.ActionRead, keys)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching keys.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
var userIDs []uuid.UUID
|
|
for _, key := range keys {
|
|
userIDs = append(userIDs, key.UserID)
|
|
}
|
|
|
|
users, _ := api.Database.GetUsersByIDs(ctx, userIDs)
|
|
usersByID := map[uuid.UUID]database.User{}
|
|
for _, user := range users {
|
|
usersByID[user.ID] = user
|
|
}
|
|
|
|
var apiKeys []codersdk.APIKeyWithOwner
|
|
for _, key := range keys {
|
|
if user, exists := usersByID[key.UserID]; exists {
|
|
apiKeys = append(apiKeys, codersdk.APIKeyWithOwner{
|
|
APIKey: convertAPIKey(key),
|
|
Username: user.Username,
|
|
})
|
|
} else {
|
|
apiKeys = append(apiKeys, codersdk.APIKeyWithOwner{
|
|
APIKey: convertAPIKey(key),
|
|
Username: "",
|
|
})
|
|
}
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, apiKeys)
|
|
}
|
|
|
|
// @Summary Delete API key
|
|
// @ID delete-api-key
|
|
// @Security CoderSessionToken
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Param keyid path string true "Key ID" format(string)
|
|
// @Success 204
|
|
// @Router /api/v2/users/{user}/keys/{keyid} [delete]
|
|
func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
keyID = chi.URLParam(r, "keyid")
|
|
auditor = api.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.APIKey](rw, &audit.RequestParams{
|
|
Audit: *auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionDelete,
|
|
})
|
|
key, err = api.Database.GetAPIKeyByID(ctx, keyID)
|
|
)
|
|
if err != nil {
|
|
api.Logger.Warn(ctx, "get API Key for audit log")
|
|
}
|
|
aReq.Old = key
|
|
defer commitAudit()
|
|
|
|
err = api.Database.DeleteAPIKeyByID(ctx, keyID)
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error deleting API key.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// @Summary Expire API key
|
|
// @ID expire-api-key
|
|
// @Security CoderSessionToken
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Param keyid path string true "Key ID" format(string)
|
|
// @Success 204
|
|
// @Failure 404 {object} codersdk.Response
|
|
// @Failure 500 {object} codersdk.Response
|
|
// @Router /api/v2/users/{user}/keys/{keyid}/expire [put]
|
|
func (api *API) expireAPIKey(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
keyID = chi.URLParam(r, "keyid")
|
|
auditor = api.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.APIKey](rw, &audit.RequestParams{
|
|
Audit: *auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionWrite,
|
|
})
|
|
)
|
|
defer commitAudit()
|
|
|
|
if err := api.Database.InTx(func(db database.Store) error {
|
|
key, err := db.GetAPIKeyByID(ctx, keyID)
|
|
if err != nil {
|
|
return xerrors.Errorf("fetch API key: %w", err)
|
|
}
|
|
if !key.ExpiresAt.After(api.Clock.Now()) {
|
|
return nil // Already expired
|
|
}
|
|
aReq.Old = key
|
|
if err := db.UpdateAPIKeyByID(ctx, database.UpdateAPIKeyByIDParams{
|
|
ID: key.ID,
|
|
LastUsed: key.LastUsed,
|
|
ExpiresAt: dbtime.Now(),
|
|
IPAddress: key.IPAddress,
|
|
}); err != nil {
|
|
return xerrors.Errorf("expire API key: %w", err)
|
|
}
|
|
// Fetch the updated key for audit log.
|
|
newKey, err := db.GetAPIKeyByID(ctx, keyID)
|
|
if err != nil {
|
|
api.Logger.Warn(ctx, "failed to fetch updated API key for audit log", slog.Error(err))
|
|
} else {
|
|
aReq.New = newKey
|
|
}
|
|
return nil
|
|
}, nil); httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
} else if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error expiring API key.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// @Summary Get token config
|
|
// @ID get-token-config
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags General
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Success 200 {object} codersdk.TokenConfig
|
|
// @Router /api/v2/users/{user}/keys/tokens/tokenconfig [get]
|
|
func (api *API) tokenConfig(rw http.ResponseWriter, r *http.Request) {
|
|
user := httpmw.UserParam(r)
|
|
maxLifetime, err := api.getMaxTokenLifetime(r.Context(), user.ID)
|
|
if err != nil {
|
|
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to get token configuration.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(
|
|
r.Context(), rw, http.StatusOK,
|
|
codersdk.TokenConfig{
|
|
MaxTokenLifetime: maxLifetime,
|
|
},
|
|
)
|
|
}
|
|
|
|
func (api *API) validateAPIKeyLifetime(ctx context.Context, userID uuid.UUID, lifetime time.Duration) error {
|
|
if lifetime <= 0 {
|
|
return xerrors.New("lifetime must be positive number greater than 0")
|
|
}
|
|
|
|
maxLifetime, err := api.getMaxTokenLifetime(ctx, userID)
|
|
if err != nil {
|
|
return xerrors.Errorf("failed to get max token lifetime: %w", err)
|
|
}
|
|
|
|
if lifetime > maxLifetime {
|
|
return xerrors.Errorf(
|
|
"lifetime must be less than %v",
|
|
maxLifetime,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getMaxTokenLifetime returns the maximum allowed token lifetime for a user.
|
|
// It distinguishes between regular users and owners.
|
|
func (api *API) getMaxTokenLifetime(ctx context.Context, userID uuid.UUID) (time.Duration, error) {
|
|
subject, _, err := httpmw.UserRBACSubject(ctx, api.Database, userID, rbac.ScopeAll)
|
|
if err != nil {
|
|
return 0, xerrors.Errorf("failed to get user rbac subject: %w", err)
|
|
}
|
|
|
|
roles, err := subject.Roles.Expand()
|
|
if err != nil {
|
|
return 0, xerrors.Errorf("failed to expand user roles: %w", err)
|
|
}
|
|
|
|
maxLifetime := api.DeploymentValues.Sessions.MaximumTokenDuration.Value()
|
|
for _, role := range roles {
|
|
if role.Identifier.Name == codersdk.RoleOwner {
|
|
// Owners have a different max lifetime.
|
|
maxLifetime = api.DeploymentValues.Sessions.MaximumAdminTokenDuration.Value()
|
|
break
|
|
}
|
|
}
|
|
|
|
return maxLifetime, nil
|
|
}
|
|
|
|
func (api *API) createAPIKey(ctx context.Context, params apikey.CreateParams) (*http.Cookie, *database.APIKey, error) {
|
|
key, sessionToken, err := apikey.Generate(params)
|
|
if err != nil {
|
|
return nil, nil, xerrors.Errorf("generate API key: %w", err)
|
|
}
|
|
|
|
newkey, err := api.Database.InsertAPIKey(ctx, key)
|
|
if err != nil {
|
|
return nil, nil, xerrors.Errorf("insert API key: %w", err)
|
|
}
|
|
|
|
api.Telemetry.Report(&telemetry.Snapshot{
|
|
APIKeys: []telemetry.APIKey{telemetry.ConvertAPIKey(newkey)},
|
|
})
|
|
|
|
return api.DeploymentValues.HTTPCookies.Apply(&http.Cookie{
|
|
Name: codersdk.SessionTokenCookie,
|
|
Value: sessionToken,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
// MaxAge is set so the browser persists the cookie to disk rather
|
|
// than keeping it in memory as a session cookie. Standalone PWAs
|
|
// (display: standalone) run in their own browser process, and
|
|
// mobile OSes kill that process when the app is swiped away —
|
|
// deleting in-memory cookies and forcing an unexpected login.
|
|
//
|
|
// We use a long static value (1 year) instead of the key's
|
|
// LifetimeSeconds because the server refreshes the key's
|
|
// ExpiresAt on activity but does not re-set the cookie. Tying
|
|
// MaxAge to the key lifetime would cause the cookie to expire
|
|
// client-side even when the server-side key is still valid.
|
|
//
|
|
// Security is not affected: the server validates ExpiresAt on
|
|
// every request regardless of the cookie's MaxAge.
|
|
MaxAge: int((365 * 24 * time.Hour).Seconds()),
|
|
}), &newkey, nil
|
|
}
|