shomei-server-0.3.0.0: app/Shomei/Admin/OAuthClients.hs
-- | @shomei-admin oauth-clients@ (MasterPlan 7 EP-5): register, list, and revoke the OAuth2 \/
-- OIDC relying parties that drive the authorization-code flow.
--
-- @
-- shomei-admin oauth-clients create --display-name TEXT --type confidential|public
-- --redirect-uri URI [--redirect-uri URI]...
-- [--scope S]...
-- shomei-admin oauth-clients list
-- shomei-admin oauth-clients revoke \<client_id\>
-- @
--
-- Registration is static — this CLI managing database rows — and there is no dynamic-registration
-- endpoint (RFC 7591). Nor are clients declarable in the config file: that would recreate exactly
-- the dual-source situation EP-4 is deprecating for service accounts.
--
-- __A confidential client's secret is generated here and printed exactly once.__ Only its SHA-256
-- digest is persisted. There is no @rotate-secret@ in this plan: unlike a service account, an
-- OAuth client's credential is held by an application an operator also controls, and a compromised
-- one is handled by revoking and re-registering. (Add rotation here if that proves wrong.)
--
-- A __public__ client (a browser SPA, a native or CLI app) has no secret at all. Its only binding
-- between the authorize and the token request is PKCE, which is therefore mandatory for it.
--
-- Unlike a service account, an OAuth client gets __no backing @shomei_users@ row__: it is never a
-- token subject. The token it exchanges a code for belongs to whichever user authenticated at
-- @\/oauth\/authorize@.
module Shomei.Admin.OAuthClients
( OAuthClientsCommand (..),
oauthClientsParser,
runOAuthClients,
-- * Actions
--
-- | The subcommands as functions that /return/ what they did, with printing left to
-- 'runOAuthClients'. Exported so the integration suite can assert on the generated secret
-- without capturing the process's stdout — which is not safe to do in a test runner that
-- executes cases in parallel (an EP-4 discovery).
createAction,
revokeAction,
listAction,
)
where
import Control.Monad (forM_, unless, when)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Time (UTCTime)
import Effectful (Eff, IOE, runEff)
import Effectful.Error.Static (Error, runErrorNoCallStack)
import Options.Applicative
import Shomei.Account.Password.Hash.Postgres (generateOpaqueToken)
import Shomei.Admin.Env (AdminEnv (..))
import Shomei.Audit.Event.Domain qualified as Event
import Shomei.Persistence.Pool.Postgres (Pool)
-- Read by /record pattern/ only, never through @OverloadedRecordDot@: 'OAuthClient' shares
-- @clientId@ / @displayName@ / @createdAt@ / @status@ with 'Shomei.ServiceAccount.Domain'.
import Shomei.Audit.Publisher.Postgres (runAuthEventPublisherPostgres)
import Shomei.Audit.Publisher.Store (AuthEventPublisher, publishAuthEvent)
import Shomei.Authorization.Claims.Domain (Scope (..))
import Shomei.Error (AuthError)
import Shomei.Id (OAuthClientId, genOAuthClientId, idText)
import Shomei.OAuth.Client.Domain
( ClientType (..),
NewOAuthClient (..),
OAuthClient (..),
OAuthClientStatus (..),
)
import Shomei.OAuth.Client.Postgres (runOAuthClientStorePostgres)
import Shomei.OAuth.Client.Store
( OAuthClientStore,
findOAuthClientByClientId,
listOAuthClients,
revokeOAuthClient,
)
import Shomei.OAuth.Client.Workflow (ClientRegistrationError (..), registerOAuthClient)
import Shomei.Persistence.Database.Postgres (Database, runDatabasePool)
import Shomei.ServiceAccount.Secret (sha256Hex)
import Shomei.Time.Postgres (runClockIO)
import Shomei.Time.Store (Clock, now)
import System.Exit (exitFailure)
import System.IO (hPutStrLn, stderr)
-- The command tree -----------------------------------------------------------
data OAuthClientsCommand
= -- | display name, client type, redirect URIs, allowed scopes
OAuthClientsCreate Text ClientType [Text] [Text]
| OAuthClientsRevoke Text
| OAuthClientsList
oauthClientsParser :: Parser OAuthClientsCommand
oauthClientsParser =
hsubparser
( command "create" (info createOpts (progDesc "Register an OAuth2/OIDC client; prints a confidential client's secret once"))
<> command "revoke" (info (OAuthClientsRevoke <$> clientIdArg) (progDesc "Refuse all future authorize and token requests for this client"))
<> command "list" (info (pure OAuthClientsList) (progDesc "Show every OAuth client (never its secret hash)"))
)
where
createOpts =
OAuthClientsCreate
<$> (Text.pack <$> strOption (long "display-name" <> metavar "TEXT" <> help "Human label, e.g. \"grafana\""))
<*> option
readClientType
( long "type"
<> metavar "confidential|public"
<> help "confidential: can keep a secret. public: cannot (SPA, native app); PKCE is then mandatory"
)
<*> many (Text.pack <$> strOption (long "redirect-uri" <> metavar "URI" <> help "An exact redirect target; repeatable. Matched by exact string equality"))
<*> many (Text.pack <$> strOption (long "scope" <> metavar "SCOPE" <> help "A scope this client may request; repeatable"))
clientIdArg = Text.pack <$> argument str (metavar "CLIENT_ID")
readClientType :: ReadM ClientType
readClientType = eitherReader \case
"confidential" -> Right ConfidentialClient
"public" -> Right PublicClient
other -> Left ("must be confidential|public, got " <> other)
-- Field accessors ------------------------------------------------------------
ocId :: OAuthClient -> OAuthClientId
ocId OAuthClient {oauthClientId} = oauthClientId
ocClientId :: OAuthClient -> Text
ocClientId OAuthClient {clientId} = clientId
ocClientType :: OAuthClient -> ClientType
ocClientType OAuthClient {clientType} = clientType
ocDisplayName :: OAuthClient -> Text
ocDisplayName OAuthClient {displayName} = displayName
ocRedirectUris :: OAuthClient -> [Text]
ocRedirectUris OAuthClient {redirectUris} = redirectUris
ocAllowedScopes :: OAuthClient -> Set Scope
ocAllowedScopes OAuthClient {allowedScopes} = allowedScopes
ocStatus :: OAuthClient -> OAuthClientStatus
ocStatus OAuthClient {status} = status
ocCreatedAt :: OAuthClient -> UTCTime
ocCreatedAt OAuthClient {createdAt} = createdAt
-- Execution ------------------------------------------------------------------
-- | Register a client, returning it and — for a confidential client — the generated secret.
-- The secret is returned, never re-read: only its digest is persisted.
createAction :: AdminEnv -> Text -> ClientType -> [Text] -> [Text] -> IO (OAuthClient, Maybe Text)
createAction env displayName clientType rawUris rawScopes = do
redirectUris <- parseRedirectUris rawUris
when (Text.length displayName > 256) (die "display name exceeds 256 characters")
scopes <- parseScopes rawScopes
-- A public client is issued no secret at all, rather than one that is ignored: a credential
-- that exists but is never checked is worse than none, because an operator will store it.
mSecret <- case clientType of
ConfidentialClient -> Just <$> generateOpaqueToken
PublicClient -> pure Nothing
registration <- runOrDie env.pool do
ocid <- genOAuthClientId
let cid = idText ocid
ts <- now
result <-
registerOAuthClient
env.config
NewOAuthClient
{ oauthClientId = ocid,
clientId = cid,
secretHash = sha256Hex <$> mSecret,
clientType,
displayName,
redirectUris,
allowedScopes = scopes,
createdAt = ts
}
case result of
Left refused -> pure (Left refused)
Right client -> do
publishAuthEvent
( Event.OAuthClientCreated
Event.OAuthClientCreatedData
{ oauthClientId = cid,
clientId = cid,
clientType = renderClientType clientType,
displayName,
redirectUris,
allowedScopes = scopes,
occurredAt = ts
}
)
pure (Right client)
client <- case registration of
Left (PrivilegeScopesRefused refused) -> case Set.toList refused of
[Scope scope] ->
die
( "refused: "
<> Text.unpack scope
<> " is a privilege scope and cannot be registered on an OAuth client (it would make every user who authorizes through it hold it); grant it to a service account instead"
)
_ ->
die
( "refused: "
<> renderScopes refused
<> " are privilege scopes and cannot be registered on an OAuth client (they would make every user who authorizes through it hold them); grant them to a service account instead"
)
Right registered -> pure registered
pure (client, mSecret)
revokeAction :: AdminEnv -> Text -> IO OAuthClient
revokeAction env cid = do
client <- requireClient env cid
runOrDie env.pool do
ts <- now
revokeOAuthClient (ocId client) ts
publishAuthEvent
( Event.OAuthClientRevoked
Event.OAuthClientRevokedData
{ oauthClientId = idText (ocId client),
clientId = ocClientId client,
occurredAt = ts
}
)
pure client
listAction :: AdminEnv -> IO [OAuthClient]
listAction env = runOrDie env.pool listOAuthClients
runOAuthClients :: AdminEnv -> OAuthClientsCommand -> IO ()
runOAuthClients env = \case
OAuthClientsCreate displayName clientType rawUris rawScopes -> do
(client, mSecret) <- createAction env displayName clientType rawUris rawScopes
putStrLn ("client_id: " <> Text.unpack (ocClientId client))
case mSecret of
Just secret ->
putStrLn ("client_secret: " <> Text.unpack secret <> " (shown once - store it now, it cannot be retrieved)")
Nothing ->
putStrLn "client_secret: (none - public client; PKCE with S256 is required at /oauth/authorize)"
putStrLn ("redirect_uris: " <> Text.unpack (Text.unwords (ocRedirectUris client)))
putStrLn ("scopes: " <> renderScopes (ocAllowedScopes client))
OAuthClientsRevoke cid -> do
client <- revokeAction env cid
putStrLn ("revoked " <> Text.unpack (ocClientId client))
-- Access tokens are stateless JWTs; revoking the client stops new grants, not old tokens.
putStrLn "note: tokens already issued through this client remain valid until they expire"
OAuthClientsList -> do
clients <- listAction env
forM_ clients \c ->
putStrLn
( Text.unpack (ocClientId c)
<> " "
<> renderStatus (ocStatus c)
<> " "
<> renderClientTypePadded (ocClientType c)
<> " "
<> Text.unpack (ocDisplayName c)
<> " redirect_uris=["
<> Text.unpack (Text.unwords (ocRedirectUris c))
<> "] scopes=["
<> renderScopes (ocAllowedScopes c)
<> "] created="
<> show (ocCreatedAt c)
)
-- | Resolve a @client_id@ or exit 1, so a typo fails loudly instead of updating zero rows.
requireClient :: AdminEnv -> Text -> IO OAuthClient
requireClient env cid = do
found <- runOrDie env.pool (findOAuthClientByClientId cid)
maybe (die ("no oauth client with client_id " <> Text.unpack cid)) pure found
renderStatus :: OAuthClientStatus -> String
renderStatus = \case
OAuthClientActive -> "active "
OAuthClientRevoked -> "revoked"
renderClientType :: ClientType -> Text
renderClientType = \case
ConfidentialClient -> "confidential"
PublicClient -> "public"
renderClientTypePadded :: ClientType -> String
renderClientTypePadded = \case
ConfidentialClient -> "confidential"
PublicClient -> "public "
renderScopes :: Set Scope -> String
renderScopes scopes = Text.unpack (Text.unwords [s | Scope s <- Set.toList scopes])
-- | Scopes are trimmed and must be non-blank; a scope containing whitespace could never be
-- expressed in the space-delimited OAuth2 @scope@ parameter.
parseScopes :: [Text] -> IO (Set Scope)
parseScopes raws = Set.fromList <$> traverse one raws
where
one raw =
let trimmed = Text.strip raw
in if Text.null trimmed || Text.any (== ' ') trimmed
then die "invalid scope (must be non-blank, contain no spaces, and have at most 128 characters)"
else pure (Scope trimmed)
-- | At least one redirect URI, each absolute and fragment-free.
--
-- A client with no registered URI could never complete a flow, and RFC 6749 §3.1.2 requires the
-- URI to be absolute and to carry no fragment (the fragment is where an implicit-flow response
-- would land, and the browser never sends it to the server anyway).
parseRedirectUris :: [Text] -> IO [Text]
parseRedirectUris raws = do
uris <- traverse one raws
unless (not (null uris)) (die "at least one --redirect-uri is required")
pure uris
where
one raw =
let trimmed = Text.strip raw
in if not (any (`Text.isPrefixOf` trimmed) ["http://", "https://"])
then die ("invalid redirect-uri: " <> show raw <> " (must be an absolute http(s) URL)")
else
if Text.any (== '#') trimmed
then die ("invalid redirect-uri: " <> show raw <> " (must carry no fragment)")
else pure trimmed
-- | The minimal chain these commands need. No @UserStore@: an OAuth client has no backing user.
runOAuthClientsEff ::
Pool ->
Eff '[OAuthClientStore, AuthEventPublisher, Clock, Database, Error AuthError, IOE] a ->
IO (Either AuthError a)
runOAuthClientsEff pool =
runEff
. runErrorNoCallStack
. runDatabasePool pool
. runClockIO
. runAuthEventPublisherPostgres
. runOAuthClientStorePostgres
runOrDie ::
Pool ->
Eff '[OAuthClientStore, AuthEventPublisher, Clock, Database, Error AuthError, IOE] a ->
IO a
runOrDie pool act = do
res <- runOAuthClientsEff pool act
either (\e -> die ("database error: " <> show e)) pure res
die :: String -> IO a
die msg = hPutStrLn stderr ("shomei-admin: " <> msg) >> exitFailure