Compare commits
1 Commits
main
...
feat/steam
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e398d8acd |
@@ -133,8 +133,12 @@ export const api = {
|
||||
totpDisable: (code) => req('/auth/me/account/totp/disable', { method: 'POST', body: { code } }),
|
||||
// Linked SSO identities (self-service). Linking starts at /auth/sso/:id/link.
|
||||
myIdentities: () => req('/auth/me/account/identities'),
|
||||
unlinkIdentity: (provider) =>
|
||||
req(`/auth/me/account/identities/${encodeURIComponent(provider)}`, { method: 'DELETE' }),
|
||||
// With an identity id, only that one goes (a person may hold several Steam accounts).
|
||||
unlinkIdentity: (provider, id) =>
|
||||
req(
|
||||
`/auth/me/account/identities/${encodeURIComponent(provider)}${id != null ? `?id=${encodeURIComponent(id)}` : ''}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
// Recovery (backup) codes. status → remaining count; generate → a fresh set,
|
||||
// returned ONCE (password step-up for accounts that have a password).
|
||||
recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Inline SVG brand icons for SSO providers. No binary assets — these scale
|
||||
// crisply at any size and keep their own brand colors. `icon` matches the
|
||||
// provider `kind` from the discovery endpoint ('google' | 'discord' | oidc/oauth2).
|
||||
// provider `kind` from the discovery endpoint ('google' | 'discord' | 'steam' | oidc/oauth2).
|
||||
// Anything unknown falls back to a neutral key glyph in the current text color.
|
||||
|
||||
function GoogleMark({ size }) {
|
||||
@@ -22,6 +22,16 @@ function DiscordMark({ size }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Steam's mark in the current text color: its brand ink is near-black, which
|
||||
// would vanish on the dark theme.
|
||||
function SteamMark({ size }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
|
||||
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function GenericMark({ size }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
|
||||
@@ -33,5 +43,6 @@ function GenericMark({ size }) {
|
||||
export default function ProviderIcon({ icon, size = 18 }) {
|
||||
if (icon === 'google') return <GoogleMark size={size} />
|
||||
if (icon === 'discord') return <DiscordMark size={size} />
|
||||
if (icon === 'steam') return <SteamMark size={size} />
|
||||
return <GenericMark size={size} />
|
||||
}
|
||||
|
||||
66
client/src/components/security/linkedIdentities.jsx
Normal file
66
client/src/components/security/linkedIdentities.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import ProviderIcon from '../ProviderIcon.jsx'
|
||||
|
||||
// What the player's and the staff account pages share about a linked sign-in
|
||||
// identity. Steam is the one provider that differs (STEAM_SIGNIN.md): a person may
|
||||
// link several Steam accounts, each shows its persona name, avatar and SteamID64,
|
||||
// and unlinking one removes only the sign-in — a game link made from it stays
|
||||
// (D393).
|
||||
|
||||
// Providers a person may link more than one account of.
|
||||
const MULTI_LINK = new Set(['steam'])
|
||||
|
||||
export function canLinkAgain(providerId) {
|
||||
return MULTI_LINK.has(providerId)
|
||||
}
|
||||
|
||||
export function identityIcon(provider) {
|
||||
return provider === 'google' || provider === 'discord' || provider === 'steam' ? provider : 'oidc'
|
||||
}
|
||||
|
||||
// The outcome of a link round trip, from the query string it returns with.
|
||||
export function linkBanner(search) {
|
||||
const q = new URLSearchParams(search)
|
||||
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
|
||||
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user on this site.' }
|
||||
if (q.get('link_error') === 'refused') return { ok: false, text: 'That account cannot be linked right now. Please try again later.' }
|
||||
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
|
||||
return null
|
||||
}
|
||||
|
||||
// Providers to offer a Link button for: every enabled one not yet linked, and a
|
||||
// multi-link one (Steam) always.
|
||||
export function linkableProviders(available, linked) {
|
||||
const linkedIds = new Set(linked.map((i) => i.provider))
|
||||
return available.filter((p) => !linkedIds.has(p.id) || canLinkAgain(p.id))
|
||||
}
|
||||
|
||||
export function unlinkPrompt(identity, providerName) {
|
||||
if (identity.provider === 'steam') {
|
||||
const who = identity.display_name ? `${identity.display_name} (${identity.subject})` : identity.subject
|
||||
return `Unlink the Steam account ${who}? You will no longer be able to sign in with it. A game link made with it stays until you remove it on that game's page.`
|
||||
}
|
||||
return `Unlink ${providerName} from your account?`
|
||||
}
|
||||
|
||||
// The icon (or Steam avatar), the provider name, and what identifies the account.
|
||||
export function IdentitySummary({ identity, providerName }) {
|
||||
const isSteam = identity.provider === 'steam'
|
||||
return (
|
||||
<>
|
||||
<span style={{ display: 'inline-flex', width: 20, height: 20, color: 'var(--head)', flex: '0 0 auto' }}>
|
||||
{isSteam && identity.avatar_url ? (
|
||||
<img src={identity.avatar_url} alt="" width={20} height={20} style={{ borderRadius: 4 }} referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<ProviderIcon icon={identityIcon(identity.provider)} size={20} />
|
||||
)}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem', overflowWrap: 'anywhere' }}>
|
||||
{isSteam && identity.display_name ? `${providerName}: ${identity.display_name}` : providerName}
|
||||
</div>
|
||||
{isSteam && identity.subject && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{identity.subject}</div>}
|
||||
{!isSteam && identity.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{identity.email}</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,10 @@
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
// 1.17.0 — a module may take part in linking a sign-in identity (STEAM_SIGNIN.md
|
||||
// §4): `ctx.identities.list`, `api.onIdentityLinking`, `api.onIdentityLinked`.
|
||||
// Server-side; the client half is core's own account pages showing Steam. This
|
||||
// file bumps for the reason at the top.
|
||||
// 1.16.0 — a dry run's `detail.warnings` (Rust PLAN_REDESIGNS §11.4, D391): a
|
||||
// module's advice, shown by core's own event editor as warning findings.
|
||||
// Server-side; this file bumps for the reason at the top.
|
||||
@@ -94,4 +98,4 @@
|
||||
// but the two halves state ONE version: a module declares a single coreApi range
|
||||
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||
// answered 1.1.0 would be two answers to one question.
|
||||
export const MODULE_API_VERSION = '1.16.0'
|
||||
export const MODULE_API_VERSION = '1.17.0'
|
||||
|
||||
@@ -5,6 +5,7 @@ import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisp
|
||||
import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
|
||||
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
|
||||
import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx'
|
||||
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../../components/security/linkedIdentities.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Link/unlink external SSO identities to this account. Linking redirects through
|
||||
@@ -15,13 +16,7 @@ function LinkedAccounts() {
|
||||
const [available, setAvailable] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const banner = (() => {
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
|
||||
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
|
||||
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
|
||||
return null
|
||||
})()
|
||||
const banner = linkBanner(window.location.search)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -40,12 +35,11 @@ function LinkedAccounts() {
|
||||
}, [load])
|
||||
|
||||
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
|
||||
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
|
||||
|
||||
async function unlink(provider) {
|
||||
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||
async function unlink(identity) {
|
||||
if (!window.confirm(unlinkPrompt(identity, nameFor(identity.provider)))) return
|
||||
try {
|
||||
await api.unlinkIdentity(provider)
|
||||
await api.unlinkIdentity(identity.provider, identity.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink.')
|
||||
@@ -55,8 +49,7 @@ function LinkedAccounts() {
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!linked) return null
|
||||
|
||||
const linkedIds = new Set(linked.map((i) => i.provider))
|
||||
const linkable = available.filter((p) => !linkedIds.has(p.id))
|
||||
const linkable = linkableProviders(available, linked)
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
|
||||
@@ -64,7 +57,7 @@ function LinkedAccounts() {
|
||||
Linked accounts
|
||||
</h2>
|
||||
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
Link a Google, Discord, or other SSO account so you can sign in with it. SSO can only sign in
|
||||
Link a Google, Discord, Steam, or other SSO account so you can sign in with it. SSO can only sign in
|
||||
to an account it is linked to — linking here is what grants that access.
|
||||
</p>
|
||||
|
||||
@@ -77,15 +70,9 @@ function LinkedAccounts() {
|
||||
{linked.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||
{linked.map((i) => (
|
||||
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
|
||||
<ProviderIcon icon={iconFor(i.provider)} size={20} />
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
|
||||
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
|
||||
</div>
|
||||
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
<div key={i.id ?? i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<IdentitySummary identity={i} providerName={nameFor(i.provider)} />
|
||||
<button onClick={() => unlink(i)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,14 @@ import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin config for authentication providers. Local password + TOTP is always on
|
||||
// (informational tab). Google/Discord are built-ins with a fixed config surface
|
||||
// (Enabled + Client ID + Client Secret). Custom providers use the full OIDC editor.
|
||||
// (Enabled + Client ID + Client Secret); Steam is a built-in with Enabled + a
|
||||
// Steam Web API key. Custom providers use the full OIDC editor.
|
||||
|
||||
const TABS = [
|
||||
{ id: 'local', label: 'Local Accounts' },
|
||||
{ id: 'google', label: 'Google' },
|
||||
{ id: 'discord', label: 'Discord' },
|
||||
{ id: 'steam', label: 'Steam' },
|
||||
{ id: 'custom', label: 'Custom Providers' },
|
||||
]
|
||||
|
||||
@@ -141,6 +143,87 @@ function BuiltinForm({ provider, onSaved }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Steam (OpenID 2.0) ─────────────────────────────────────────────────────
|
||||
// No client id and no redirect URL to register: Steam's sign-in takes neither.
|
||||
// What it needs is a Steam Web API key, without which it cannot be enabled
|
||||
// (STEAM_SIGNIN.md D394) — the key is how a linked account shows its persona
|
||||
// name and avatar. Steam never creates an account (D395): a player links Steam
|
||||
// to the account they already have.
|
||||
function SteamForm({ provider, onSaved }) {
|
||||
const [enabled, setEnabled] = useState(provider.enabled)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function save() {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
const body = { enabled }
|
||||
if (apiKey) body.secret = apiKey // only send a new key when entered
|
||||
await api.admin.updateAuthProvider(provider.id, body)
|
||||
setApiKey('')
|
||||
setMsg('Saved.')
|
||||
await onSaved()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ display: 'inline-flex', width: 26, height: 26, color: 'var(--head)' }}>
|
||||
<ProviderIcon icon="steam" size={26} />
|
||||
</span>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
{provider.name}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Players link their Steam account from their account page and can then sign in with it. Steam never
|
||||
creates an account: an unlinked Steam account signs nobody in, whatever the registration setting.
|
||||
</p>
|
||||
|
||||
<Toggle checked={enabled} onChange={setEnabled} label="Enable this sign-in method" />
|
||||
<HealthWarning provider={provider} />
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Steam Web API key (required)</span>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="new-password"
|
||||
placeholder={provider.hasSecret ? '•••••••• configured — leave blank to keep' : 'Steam Web API key'}
|
||||
/>
|
||||
</label>
|
||||
<p className="sans dim" style={{ margin: '-8px 0 0', fontSize: '0.82rem', lineHeight: 1.5 }}>
|
||||
Get one at{' '}
|
||||
<a href="https://steamcommunity.com/dev/apikey" target="_blank" rel="noreferrer" className="link-accent">
|
||||
steamcommunity.com/dev/apikey
|
||||
</a>{' '}
|
||||
with this site's domain. It is used only to show a linked account's Steam name and avatar, and is
|
||||
never sent to a browser. No redirect URL needs registering with Steam.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
|
||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Local accounts (informational) ─────────────────────────────────────────
|
||||
function LocalInfo() {
|
||||
return (
|
||||
@@ -376,6 +459,7 @@ export default function AuthProvidersAdmin() {
|
||||
{tab === 'local' && <LocalInfo />}
|
||||
{tab === 'google' && <BuiltinForm provider={byId('google')} onSaved={load} />}
|
||||
{tab === 'discord' && <BuiltinForm provider={byId('discord')} onSaved={load} />}
|
||||
{tab === 'steam' && byId('steam') && <SteamForm key="steam" provider={byId('steam')} onSaved={load} />}
|
||||
{tab === 'custom' && <CustomProviders items={customs} onChanged={load} />}
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay
|
||||
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
|
||||
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
|
||||
import EmailAddressPanel from '../../components/security/EmailAddressPanel.jsx'
|
||||
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../components/security/linkedIdentities.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
@@ -224,13 +225,7 @@ function LinkedAccounts() {
|
||||
const [available, setAvailable] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const banner = (() => {
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
|
||||
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
|
||||
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
|
||||
return null
|
||||
})()
|
||||
const banner = linkBanner(window.location.search)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -247,12 +242,11 @@ function LinkedAccounts() {
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
|
||||
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
|
||||
|
||||
async function unlink(provider) {
|
||||
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||
async function unlink(identity) {
|
||||
if (!window.confirm(unlinkPrompt(identity, nameFor(identity.provider)))) return
|
||||
try {
|
||||
await api.unlinkIdentity(provider)
|
||||
await api.unlinkIdentity(identity.provider, identity.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink.')
|
||||
@@ -262,13 +256,12 @@ function LinkedAccounts() {
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!linked) return null
|
||||
|
||||
const linkedIds = new Set(linked.map((i) => i.provider))
|
||||
const linkable = available.filter((p) => !linkedIds.has(p.id))
|
||||
const linkable = linkableProviders(available, linked)
|
||||
|
||||
return (
|
||||
<Section title="Linked accounts">
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Link a Google, Discord, or other provider so you can sign in with it.
|
||||
Link a Google, Discord, Steam, or other provider so you can sign in with it.
|
||||
</p>
|
||||
{banner && (
|
||||
<p className="sans" style={{ color: banner.ok ? '#7fd0a4' : '#d98b84', fontSize: '0.86rem' }}>{banner.text}</p>
|
||||
@@ -276,15 +269,9 @@ function LinkedAccounts() {
|
||||
{linked.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||
{linked.map((i) => (
|
||||
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
|
||||
<ProviderIcon icon={iconFor(i.provider)} size={20} />
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
|
||||
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
|
||||
</div>
|
||||
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>Unlink</button>
|
||||
<div key={i.id ?? i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<IdentitySummary identity={i} providerName={nameFor(i.provider)} />
|
||||
<button onClick={() => unlink(i)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>Unlink</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -179,7 +179,7 @@ CREATE TABLE IF NOT EXISTS activity_log (
|
||||
-- custom (oidc/oauth2) providers.
|
||||
CREATE TABLE IF NOT EXISTS auth_providers (
|
||||
id VARCHAR(64) PRIMARY KEY, -- 'google' | 'discord' | custom slug
|
||||
kind ENUM('google','discord','oidc','oauth2') NOT NULL,
|
||||
kind ENUM('google','discord','oidc','oauth2','steam') NOT NULL,
|
||||
name VARCHAR(80) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
client_id VARCHAR(255) NULL,
|
||||
@@ -203,6 +203,8 @@ CREATE TABLE IF NOT EXISTS user_identities (
|
||||
provider VARCHAR(64) NOT NULL, -- matches auth_providers.id
|
||||
subject VARCHAR(191) NOT NULL, -- external stable user id (sub / discord id)
|
||||
email VARCHAR(255) NULL,
|
||||
display_name VARCHAR(191) NULL, -- Steam persona name (D394); null for other providers
|
||||
avatar_url VARCHAR(500) NULL, -- Steam avatar, a Steam CDN https URL; null for others
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uq_identity_provider_subject (provider, subject),
|
||||
@@ -2744,3 +2746,12 @@ CREATE TABLE IF NOT EXISTS event_trigger_starts (
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evtrig_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Sign in with Steam (STEAM_SIGNIN.md §4). Steam is a built-in provider beside
|
||||
-- Google and Discord, so its row needs the kind; and a linked Steam account
|
||||
-- shows its persona name and avatar (D394), kept on the identity and refreshed
|
||||
-- at each Steam sign-in. Both additive. MODIFY has no IF NOT EXISTS form, but
|
||||
-- re-declaring the same ENUM is an idempotent no-op.
|
||||
ALTER TABLE auth_providers MODIFY COLUMN kind ENUM('google','discord','oidc','oauth2','steam') NOT NULL;
|
||||
ALTER TABLE user_identities ADD COLUMN IF NOT EXISTS display_name VARCHAR(191) NULL;
|
||||
ALTER TABLE user_identities ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(500) NULL;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
|
||||
"moduleApiVersion": "1.16.0",
|
||||
"moduleApiVersion": "1.17.0",
|
||||
"triggers": [
|
||||
{
|
||||
"id": "event.phase.changed",
|
||||
|
||||
@@ -1834,7 +1834,7 @@
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/account/identities/:provider",
|
||||
"handlers": 3,
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
|
||||
97
server/src/auth/identityHooks.js
Normal file
97
server/src/auth/identityHooks.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// ── Modules and identity links (MODULE_API 1.17.0) ─────────────────────────
|
||||
//
|
||||
// Core owns linking an external sign-in identity to an account. Modules take part
|
||||
// in exactly two places (STEAM_SIGNIN.md §4):
|
||||
//
|
||||
// checkLink() before the identity is written. Each module that registered
|
||||
// `onIdentityLinking` may refuse. Rust uses it for D396: a Steam
|
||||
// id that is already another website account's Rust link.
|
||||
// notifyLinked() after it is written. Rust uses it for D392: the Steam account
|
||||
// becomes a linked Rust player.
|
||||
//
|
||||
// A refusal can only stop a link. It runs inside the link flow of a user who is
|
||||
// already signed in, so nothing here can mint a session or let through a link
|
||||
// core would refuse.
|
||||
//
|
||||
// What a module says is never shown to the player: `reason: 'in_use'` reads as
|
||||
// the same "already linked to another user" core says itself, and anything else
|
||||
// as a plain refusal. A module's own words go to the log. Putting module text in
|
||||
// a redirect URL would let anybody craft a link that prints a sentence of their
|
||||
// choosing on our account page.
|
||||
|
||||
const loader = require('../modules/loader')
|
||||
const log = require('../utils/logger')('sso')
|
||||
|
||||
const HOOK_TIMEOUT_MS = 5_000
|
||||
|
||||
function withTimeout(fn, ms) {
|
||||
let timer
|
||||
const deadline = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`no answer within ${ms} ms`)), ms)
|
||||
})
|
||||
return Promise.race([Promise.resolve().then(fn), deadline]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask every started module whether this link may be made.
|
||||
*
|
||||
* **Fails closed.** A module that throws or does not answer in time refuses the
|
||||
* link: the hook exists to stop a link that must not happen, and a module that
|
||||
* could not look is a module that could not say it was safe. The player is told
|
||||
* to try again; a link is never the urgent half of anything.
|
||||
*
|
||||
* @param {{userId: number, provider: string, subject: string}} link
|
||||
* @returns {Promise<{ok: true} | {ok: false, reason: 'in_use' | 'refused', module: string}>}
|
||||
*/
|
||||
async function checkLink(link) {
|
||||
const frozen = Object.freeze({ userId: link.userId, provider: link.provider, subject: link.subject })
|
||||
for (const { id, hook } of loader.identityHooks('onIdentityLinking')) {
|
||||
let answer
|
||||
try {
|
||||
answer = await withTimeout(() => hook(frozen), HOOK_TIMEOUT_MS)
|
||||
} catch (err) {
|
||||
log.error('identity link check failed; refusing the link', { module: id, provider: frozen.provider, message: err.message })
|
||||
return { ok: false, reason: 'refused', module: id }
|
||||
}
|
||||
if (answer && answer.ok === false) {
|
||||
const reason = answer.reason === 'in_use' ? 'in_use' : 'refused'
|
||||
log.info('identity link refused by a module', {
|
||||
module: id,
|
||||
provider: frozen.provider,
|
||||
userId: frozen.userId,
|
||||
reason,
|
||||
message: typeof answer.message === 'string' ? answer.message.slice(0, 200) : undefined,
|
||||
})
|
||||
return { ok: false, reason, module: id }
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell every started module a link was written.
|
||||
*
|
||||
* Awaited (bounded) so the page the player lands on already shows what a module
|
||||
* made of it — the Rust page listing the Steam account they just linked — but a
|
||||
* failure never reaches the link: the identity is written, and that is core's
|
||||
* answer. A module that missed it is logged.
|
||||
*
|
||||
* @param {{userId: number, provider: string, subject: string, displayName?: string|null}} link
|
||||
*/
|
||||
async function notifyLinked(link) {
|
||||
const frozen = Object.freeze({
|
||||
userId: link.userId,
|
||||
provider: link.provider,
|
||||
subject: link.subject,
|
||||
displayName: link.displayName || null,
|
||||
})
|
||||
await Promise.all(
|
||||
loader.identityHooks('onIdentityLinked').map(({ id, hook }) =>
|
||||
withTimeout(() => hook(frozen), HOOK_TIMEOUT_MS).catch((err) => {
|
||||
log.error('identity linked hook failed', { module: id, provider: frozen.provider, message: err.message })
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { checkLink, notifyLinked, HOOK_TIMEOUT_MS }
|
||||
@@ -12,6 +12,7 @@
|
||||
const GoogleProvider = require('./google.provider')
|
||||
const DiscordProvider = require('./discord.provider')
|
||||
const GenericOidcProvider = require('./genericOidc.provider')
|
||||
const SteamProvider = require('./steam.provider')
|
||||
const authProviders = require('../../model/authProviders/authProviders.model')
|
||||
|
||||
// kind → provider class.
|
||||
@@ -20,6 +21,7 @@ const KINDS = {
|
||||
discord: DiscordProvider,
|
||||
oidc: GenericOidcProvider,
|
||||
oauth2: GenericOidcProvider,
|
||||
steam: SteamProvider,
|
||||
}
|
||||
|
||||
// Built-in providers and their fixed display metadata. Endpoints are in the
|
||||
@@ -27,6 +29,9 @@ const KINDS = {
|
||||
const BUILTINS = [
|
||||
{ id: 'google', kind: 'google', name: 'Google', priority: 1 },
|
||||
{ id: 'discord', kind: 'discord', name: 'Discord', priority: 2 },
|
||||
// OpenID 2.0, not OAuth2 (STEAM_SIGNIN.md §3): no client id, and the "secret"
|
||||
// is the Steam Web API key it cannot be enabled without (D394).
|
||||
{ id: 'steam', kind: 'steam', name: 'Steam', priority: 3 },
|
||||
]
|
||||
|
||||
const BUILTIN_IDS = new Set(BUILTINS.map((b) => b.id))
|
||||
@@ -35,6 +40,18 @@ function isBuiltin(id) {
|
||||
return BUILTIN_IDS.has(id)
|
||||
}
|
||||
|
||||
// Providers whose callback is OpenID 2.0 (`openid.*` fields) rather than an OAuth2
|
||||
// `code`. Steam is the only one, and only as the built-in: a custom provider can
|
||||
// only be oidc/oauth2, so the kind is known from the id before any row is read.
|
||||
function isOpenId(id) {
|
||||
return id === 'steam'
|
||||
}
|
||||
|
||||
// Providers that never sign anybody up, whatever player_registration says (D395).
|
||||
function neverProvisions(kind) {
|
||||
return kind === 'steam'
|
||||
}
|
||||
|
||||
// Instantiate a provider from a config row (secret already decrypted by the
|
||||
// model as `client_secret`). Returns null for an unknown kind.
|
||||
function instantiate(row) {
|
||||
@@ -66,6 +83,11 @@ async function load(id) {
|
||||
// custom (oidc/oauth2) also need the three endpoint URLs. Returns { valid, missing }.
|
||||
function validateConfig(row) {
|
||||
const missing = []
|
||||
if (row.kind === 'steam') {
|
||||
// No client id; the Web API key is the one thing it needs (D394).
|
||||
if (!row.client_secret_enc && !row.client_secret) missing.push('api_key')
|
||||
return { valid: missing.length === 0, missing }
|
||||
}
|
||||
if (!row.client_id) missing.push('client_id')
|
||||
// A stored secret shows up as client_secret_enc on plain rows, or client_secret
|
||||
// on decrypted rows — accept either as "has a secret".
|
||||
@@ -110,7 +132,7 @@ async function listEnabledValid() {
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
icon: p.kind, // 'google' | 'discord' | 'oidc' | 'oauth2'
|
||||
icon: p.kind, // 'google' | 'discord' | 'steam' | 'oidc' | 'oauth2'
|
||||
loginUrl: `/api/v1/auth/sso/${p.id}/start`,
|
||||
priority: p.priority ?? 100,
|
||||
}))
|
||||
@@ -120,6 +142,8 @@ module.exports = {
|
||||
KINDS,
|
||||
BUILTINS,
|
||||
isBuiltin,
|
||||
isOpenId,
|
||||
neverProvisions,
|
||||
instantiate,
|
||||
load,
|
||||
validateConfig,
|
||||
|
||||
172
server/src/auth/providers/steam.provider.js
Normal file
172
server/src/auth/providers/steam.provider.js
Normal file
@@ -0,0 +1,172 @@
|
||||
// Built-in Steam provider (OpenID 2.0, STEAM_SIGNIN.md §3).
|
||||
//
|
||||
// Steam is not OAuth2: there is no client id, no token endpoint and no `state`
|
||||
// parameter. The browser goes to steamcommunity.com with an OpenID 2.0
|
||||
// `checkid_setup` request and comes back carrying `openid.*` fields, and the ONLY
|
||||
// proof those fields are genuine is asking Steam itself — a server-to-server
|
||||
// `check_authentication` POST of the same fields, which must answer
|
||||
// `is_valid:true`. Our CSRF `state` rides inside `return_to`, where the existing
|
||||
// single-use tx cookie verifies it exactly as it does for the OAuth providers.
|
||||
//
|
||||
// The admin-configured "secret" is a Steam Web API key (D394), used for one thing:
|
||||
// the persona name and avatar shown beside a linked Steam account. That lookup
|
||||
// decorates an identity and never decides a sign-in — if it fails, the sign-in
|
||||
// goes ahead with the bare id.
|
||||
|
||||
const BaseProvider = require('./base.provider')
|
||||
const log = require('../../utils/logger')('sso')
|
||||
|
||||
const OP_ENDPOINT = 'https://steamcommunity.com/openid/login'
|
||||
const OPENID_NS = 'http://specs.openid.net/auth/2.0'
|
||||
const IDENTIFIER_SELECT = 'http://specs.openid.net/auth/2.0/identifier_select'
|
||||
const CLAIMED_ID_RE = /^https:\/\/steamcommunity\.com\/openid\/id\/(\d{17})$/
|
||||
const SUMMARIES_URL = 'https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/'
|
||||
// Steam serves avatars from its own CDNs. Anything else in that field is not
|
||||
// something we render.
|
||||
const AVATAR_RE = /^https:\/\/([a-z0-9-]+\.)*(steamstatic\.com|akamaihd\.net)\/[^\s"'<>]{1,400}$/i
|
||||
|
||||
const VERIFY_TIMEOUT_MS = 10_000
|
||||
const SUMMARY_TIMEOUT_MS = 5_000
|
||||
|
||||
// A failure the controller turns into a sentence rather than a 500.
|
||||
class SteamAssertionError extends Error {
|
||||
constructor(reason) {
|
||||
super(`steam assertion refused: ${reason}`)
|
||||
this.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
// The 17-digit SteamID64 at the end of a claimed id, or null.
|
||||
function parseClaimedId(claimedId) {
|
||||
const m = typeof claimedId === 'string' ? CLAIMED_ID_RE.exec(claimedId) : null
|
||||
return m ? m[1] : null
|
||||
}
|
||||
|
||||
// The `return_to` we send, and must get back byte for byte: the callback URL with
|
||||
// our tx nonce as `state`.
|
||||
function returnToFor(redirectUri, state) {
|
||||
const sep = redirectUri.includes('?') ? '&' : '?'
|
||||
return `${redirectUri}${sep}state=${encodeURIComponent(state)}`
|
||||
}
|
||||
|
||||
// The realm is the origin the return_to lives under — what Steam shows the user
|
||||
// as "you are signing in to".
|
||||
function realmFor(redirectUri) {
|
||||
return new URL(redirectUri).origin
|
||||
}
|
||||
|
||||
// Every `openid.*` field from a parsed query, as strings. Anything else is ours
|
||||
// (`state`) and does not go back to Steam.
|
||||
function openidFields(query = {}) {
|
||||
const out = {}
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (k.startsWith('openid.') && typeof v === 'string') out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
class SteamProvider extends BaseProvider {
|
||||
constructor(config = {}) {
|
||||
super({ kind: 'steam', name: 'Steam', ...config, id: config.id || 'steam' })
|
||||
// Stored in client_secret_enc like any secret; it is a Web API key here.
|
||||
this.apiKey = config.clientSecret ?? config.client_secret ?? null
|
||||
}
|
||||
|
||||
getAuthorizationUrl(state, { redirectUri } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
'openid.ns': OPENID_NS,
|
||||
'openid.mode': 'checkid_setup',
|
||||
'openid.return_to': returnToFor(redirectUri, state),
|
||||
'openid.realm': realmFor(redirectUri),
|
||||
'openid.identity': IDENTIFIER_SELECT,
|
||||
'openid.claimed_id': IDENTIFIER_SELECT,
|
||||
})
|
||||
return `${OP_ENDPOINT}?${params.toString()}`
|
||||
}
|
||||
|
||||
// `query` is the callback's parsed query string; `redirectUri` and `state` are
|
||||
// what we sent, so `return_to` can be matched exactly. Resolves to the usual
|
||||
// { subject, email, emailVerified, name } plus displayName/avatarUrl, or throws
|
||||
// SteamAssertionError.
|
||||
async handleCallback({ query, redirectUri, state } = {}) {
|
||||
const fields = openidFields(query)
|
||||
if (fields['openid.mode'] !== 'id_res') throw new SteamAssertionError('mode')
|
||||
if (fields['openid.ns'] !== OPENID_NS) throw new SteamAssertionError('ns')
|
||||
if (fields['openid.op_endpoint'] !== OP_ENDPOINT) throw new SteamAssertionError('op_endpoint')
|
||||
if (fields['openid.return_to'] !== returnToFor(redirectUri, state)) throw new SteamAssertionError('return_to')
|
||||
const subject = parseClaimedId(fields['openid.claimed_id'])
|
||||
if (!subject) throw new SteamAssertionError('claimed_id')
|
||||
if (fields['openid.identity'] !== fields['openid.claimed_id']) throw new SteamAssertionError('identity')
|
||||
|
||||
if (!(await this.verifyAssertion(fields))) throw new SteamAssertionError('not_valid')
|
||||
|
||||
const summary = await this.playerSummary(subject)
|
||||
return {
|
||||
subject,
|
||||
email: null, // Steam never sends one
|
||||
emailVerified: false,
|
||||
name: summary.displayName,
|
||||
displayName: summary.displayName,
|
||||
avatarUrl: summary.avatarUrl,
|
||||
}
|
||||
}
|
||||
|
||||
// The one proof (§5): send the same fields back with mode check_authentication.
|
||||
// Never skipped; any transport failure is a refusal, not a pass.
|
||||
async verifyAssertion(fields) {
|
||||
const body = new URLSearchParams({ ...fields, 'openid.mode': 'check_authentication' })
|
||||
let text
|
||||
try {
|
||||
const res = await fetch(OP_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS),
|
||||
})
|
||||
if (!res.ok) {
|
||||
log.warn('steam check_authentication failed', { status: res.status })
|
||||
return false
|
||||
}
|
||||
text = await res.text()
|
||||
} catch (err) {
|
||||
log.warn('steam check_authentication unreachable', { message: err.message })
|
||||
return false
|
||||
}
|
||||
return /^is_valid:true$/m.test(text)
|
||||
}
|
||||
|
||||
// Persona name + avatar through the Web API key. Decoration only: every failure
|
||||
// answers nulls and the sign-in carries on (D394). The key rides in the URL, so
|
||||
// the URL is never logged.
|
||||
async playerSummary(steamId) {
|
||||
const none = { displayName: null, avatarUrl: null }
|
||||
if (!this.apiKey) return none
|
||||
try {
|
||||
const url = `${SUMMARIES_URL}?key=${encodeURIComponent(this.apiKey)}&steamids=${steamId}`
|
||||
const res = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(SUMMARY_TIMEOUT_MS) })
|
||||
if (!res.ok) {
|
||||
log.warn('steam player summary failed; signing in with the bare id', { status: res.status })
|
||||
return none
|
||||
}
|
||||
const data = await res.json()
|
||||
const player = data && data.response && Array.isArray(data.response.players) ? data.response.players[0] : null
|
||||
if (!player || player.steamid !== steamId) return none
|
||||
const name = typeof player.personaname === 'string' ? player.personaname.trim().slice(0, 191) : ''
|
||||
const avatar = player.avatarfull || player.avatarmedium || player.avatar
|
||||
return {
|
||||
displayName: name || null,
|
||||
avatarUrl: typeof avatar === 'string' && AVATAR_RE.test(avatar) ? avatar : null,
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('steam player summary unreachable; signing in with the bare id', { message: err.message })
|
||||
return none
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SteamProvider.SteamAssertionError = SteamAssertionError
|
||||
SteamProvider.parseClaimedId = parseClaimedId
|
||||
SteamProvider.returnToFor = returnToFor
|
||||
SteamProvider.OP_ENDPOINT = OP_ENDPOINT
|
||||
|
||||
module.exports = SteamProvider
|
||||
@@ -13,21 +13,45 @@ async function findByProviderSubject(provider, subject) {
|
||||
// All identities linked to a given internal user (for the Account page).
|
||||
async function listForUser(userId) {
|
||||
return query(
|
||||
'SELECT id, provider, subject, email, created_at FROM user_identities WHERE user_id = ? ORDER BY provider',
|
||||
`SELECT id, provider, subject, email, display_name, avatar_url, created_at
|
||||
FROM user_identities WHERE user_id = ? ORDER BY provider, created_at`,
|
||||
[userId],
|
||||
)
|
||||
}
|
||||
|
||||
async function insert({ userId, provider, subject, email = null }) {
|
||||
async function insert({ userId, provider, subject, email = null, displayName = null, avatarUrl = null }) {
|
||||
const res = await query(
|
||||
'INSERT INTO user_identities (user_id, provider, subject, email) VALUES (?, ?, ?, ?)',
|
||||
[userId, provider, subject, email],
|
||||
`INSERT INTO user_identities (user_id, provider, subject, email, display_name, avatar_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[userId, provider, subject, email, displayName, avatarUrl],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
// Remove a user's link to a provider. Returns rows deleted.
|
||||
async function deleteForUserProvider(userId, provider) {
|
||||
// Refresh the name and avatar a provider reported at sign-in (Steam, D394). Only
|
||||
// what the provider sent is written: a lookup that failed leaves the last good
|
||||
// values in place rather than blanking them.
|
||||
async function updateProfile(id, { displayName, avatarUrl }) {
|
||||
const sets = []
|
||||
const vals = []
|
||||
if (displayName) { sets.push('display_name = ?'); vals.push(displayName) }
|
||||
if (avatarUrl) { sets.push('avatar_url = ?'); vals.push(avatarUrl) }
|
||||
if (!sets.length) return 0
|
||||
const res = await query(`UPDATE user_identities SET ${sets.join(', ')} WHERE id = ?`, [...vals, id])
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
// Remove a user's link to a provider — every identity of it, or, given an
|
||||
// identity id, that one only (a user may hold several Steam accounts). Returns
|
||||
// rows deleted.
|
||||
async function deleteForUserProvider(userId, provider, identityId = null) {
|
||||
if (identityId != null) {
|
||||
const res = await query(
|
||||
'DELETE FROM user_identities WHERE user_id = ? AND provider = ? AND id = ?',
|
||||
[userId, provider, identityId],
|
||||
)
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
const res = await query(
|
||||
'DELETE FROM user_identities WHERE user_id = ? AND provider = ?',
|
||||
[userId, provider],
|
||||
@@ -35,4 +59,4 @@ async function deleteForUserProvider(userId, provider) {
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { findByProviderSubject, listForUser, insert, deleteForUserProvider }
|
||||
module.exports = { findByProviderSubject, listForUser, insert, updateProfile, deleteForUserProvider }
|
||||
|
||||
@@ -15,13 +15,21 @@ async function listForUser(userId) {
|
||||
|
||||
// Link an external identity to an internal user. Returns the new row id. The
|
||||
// (provider, subject) UNIQUE constraint enforces one-identity-one-user at the DB.
|
||||
async function link({ userId, provider, subject, email }) {
|
||||
return db.insert({ userId, provider, subject, email })
|
||||
// `displayName` / `avatarUrl` are set by providers that report them (Steam);
|
||||
// the others leave them null.
|
||||
async function link({ userId, provider, subject, email, displayName, avatarUrl }) {
|
||||
return db.insert({ userId, provider, subject, email, displayName, avatarUrl })
|
||||
}
|
||||
|
||||
// Unlink a provider from a user. Returns rows removed (0 if nothing was linked).
|
||||
async function unlink(userId, provider) {
|
||||
return db.deleteForUserProvider(userId, provider)
|
||||
// Refresh a linked identity's name and avatar from a sign-in's profile.
|
||||
async function refreshProfile(identityId, { displayName, avatarUrl } = {}) {
|
||||
return db.updateProfile(identityId, { displayName, avatarUrl })
|
||||
}
|
||||
|
||||
module.exports = { findByProviderSubject, listForUser, link, unlink }
|
||||
// Unlink a provider from a user: every identity of it, or the one `identityId`
|
||||
// names. Returns rows removed (0 if nothing was linked).
|
||||
async function unlink(userId, provider, identityId = null) {
|
||||
return db.deleteForUserProvider(userId, provider, identityId)
|
||||
}
|
||||
|
||||
module.exports = { findByProviderSubject, listForUser, link, refreshProfile, unlink }
|
||||
|
||||
@@ -311,6 +311,26 @@ function buildCtx(id, moduleRoot) {
|
||||
// exports creation, role changes and password handling, none of which is a
|
||||
// module's business.
|
||||
users: { getById: users.getById },
|
||||
// API 1.17.0 (STEAM_SIGNIN.md §4). READ only, one provider at a time: what a
|
||||
// user has linked, as core verified it — the Steam id a module can trust is
|
||||
// the one Steam's own `check_authentication` proved, never one a player typed.
|
||||
// Linking and unlinking stay core's (the hooks on `api` are how a module takes
|
||||
// part), and the row id and email are core's business, so they are not here.
|
||||
identities: {
|
||||
list: async (userId, provider) => {
|
||||
// eslint-disable-next-line global-require
|
||||
const rows = await require('../model/userIdentities/userIdentities.model').listForUser(userId)
|
||||
return rows
|
||||
.filter((r) => r.provider === provider)
|
||||
.map((r) => ({
|
||||
provider: r.provider,
|
||||
subject: r.subject,
|
||||
displayName: r.display_name || null,
|
||||
avatarUrl: r.avatar_url || null,
|
||||
linkedAt: r.created_at,
|
||||
}))
|
||||
},
|
||||
},
|
||||
// Where this deployment is reachable, for a module that has to build an
|
||||
// absolute link (an announcement carries one into a game window or a chat
|
||||
// message, where a relative path means nothing). §2.7 forbids a module
|
||||
@@ -472,6 +492,17 @@ function buildApi(record) {
|
||||
// never calls them.
|
||||
onBoot: hook('onBoot'),
|
||||
onShutdown: hook('onShutdown'),
|
||||
// API 1.17.0 (STEAM_SIGNIN.md §4). Core calling the module about an external
|
||||
// identity a signed-in user is linking, dispatched from auth/identityHooks.js.
|
||||
//
|
||||
// `onIdentityLinking` answers before anything is written and can only REFUSE
|
||||
// (D396: a Steam id already another account's Rust link). It runs inside the
|
||||
// link flow of a user who is already signed in, so it has no path to a session
|
||||
// and cannot let through a link core would refuse. `onIdentityLinked` hears a
|
||||
// link after it is written. There is no unlink hook: D393 leaves a module
|
||||
// nothing to undo when a sign-in identity goes.
|
||||
onIdentityLinking: hook('onIdentityLinking'),
|
||||
onIdentityLinked: hook('onIdentityLinked'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,7 +825,7 @@ function load(tierRouters) {
|
||||
staged: registries.stage(id),
|
||||
tables: new Set(),
|
||||
called: new Set(),
|
||||
hooks: { onBoot: null, onShutdown: null },
|
||||
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null },
|
||||
client: null,
|
||||
ctx: null,
|
||||
state: 'installed',
|
||||
@@ -1022,6 +1053,24 @@ function shutdownHooks() {
|
||||
.reverse()
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity hooks of one name (`onIdentityLinking` | `onIdentityLinked`), in
|
||||
* registration order (API 1.17.0).
|
||||
*
|
||||
* `started` only, the rule every dispatch here follows: a module that is
|
||||
* disabled, or whose `onBoot` threw, is not serving and has no say in a link.
|
||||
* Empty in a process that never loaded modules (the seed script, unit tests), so
|
||||
* the link flow there behaves exactly as a bare core.
|
||||
*
|
||||
* @returns {{id: string, hook: Function}[]}
|
||||
*/
|
||||
function identityHooks(name) {
|
||||
if (!loaded) return []
|
||||
return [...modules.values()]
|
||||
.filter((r) => r.state === 'started' && r.hooks[name])
|
||||
.map((r) => ({ id: r.id, hook: r.hooks[name] }))
|
||||
}
|
||||
|
||||
/**
|
||||
* One module's `onShutdown`, for stopping it on its own rather than at exit.
|
||||
*
|
||||
@@ -1147,6 +1196,7 @@ module.exports = {
|
||||
fragments,
|
||||
bootable,
|
||||
shutdownHooks,
|
||||
identityHooks,
|
||||
stopHook,
|
||||
clientChunks,
|
||||
clientEntryUrls,
|
||||
|
||||
@@ -9,6 +9,21 @@
|
||||
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
||||
// has nothing to say about a website module) and from any module's own version.
|
||||
|
||||
// 1.17.0 — a module may take part in linking an external sign-in identity
|
||||
// (docs/website/STEAM_SIGNIN.md §4; Rust PLAN_REDESIGNS §15, D392 and D396).
|
||||
// Three additions, each optional:
|
||||
// • `ctx.identities.list(userId, provider)` — the identities a user has linked
|
||||
// for one provider, as core verified them (a Steam id proved by Steam's own
|
||||
// `check_authentication`), with the display name and avatar when there are;
|
||||
// • `api.onIdentityLinking(fn)` — asked before a link is written, and able only
|
||||
// to refuse it: `{ ok: false, reason }`, where `reason: 'in_use'` reads to the
|
||||
// player as "already linked to another account" and anything else as a
|
||||
// plain refusal. A module's own text is logged, never shown;
|
||||
// • `api.onIdentityLinked(fn)` — told after a link is written.
|
||||
// No unlink hook (D393). Additions only, so minor. Module-uo declares `^1.10.0`
|
||||
// and uses none of them: with no hook registered a link is checked and written
|
||||
// exactly as before.
|
||||
//
|
||||
// 1.16.0 — a dry run carries a module's advice: an action that answers
|
||||
// `verify: true` with `{ ok: true, detail: { warnings: [...] } }` has each line
|
||||
// shown as a WARNING finding (code `advice`) that does not fail the dry run
|
||||
@@ -216,6 +231,6 @@
|
||||
// an admin action a module performs belongs in core's one audit log, the
|
||||
// extension slot needs the user its prefix names, and §2.7 forbids a module
|
||||
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
|
||||
const MODULE_API_VERSION = '1.16.0'
|
||||
const MODULE_API_VERSION = '1.17.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// ── Admin: auth provider configuration ─────────────────────────────────────
|
||||
//
|
||||
// CRUD for SSO providers. Built-ins (google, discord) are configured here too but
|
||||
// CRUD for SSO providers. Built-ins (google, discord, steam) are configured here too but
|
||||
// can only be enabled/disabled and given a client id/secret — their kind, name,
|
||||
// and endpoints are fixed in code and cannot be edited or deleted. Custom
|
||||
// (oidc/oauth2) providers are fully editable.
|
||||
@@ -80,6 +80,18 @@ async function update(req, res) {
|
||||
// Built-ins: kind/name/priority are fixed; only enable + credentials change.
|
||||
const meta = registry.BUILTINS.find((x) => x.id === id)
|
||||
fields = { kind: meta.kind, name: meta.name, priority: meta.priority, enabled: b.enabled, clientId: b.clientId, secret: b.secret }
|
||||
if (meta.kind === 'steam') {
|
||||
// Steam has no client id; its secret is the Steam Web API key, and it
|
||||
// cannot be enabled without one (D394) — refused here, with a sentence,
|
||||
// rather than saved as an "Incomplete" provider nobody can use.
|
||||
fields.clientId = undefined
|
||||
if (b.enabled === true && !b.secret) {
|
||||
const current = await authProviders.get(id)
|
||||
if (!current || !current.client_secret_enc) {
|
||||
return res.status(400).json({ message: 'Steam needs a Steam Web API key before it can be enabled.' })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!(await authProviders.get(id))) {
|
||||
return res.status(404).json({ message: 'Provider not found.' })
|
||||
|
||||
@@ -59,11 +59,12 @@ providersRouter.put(
|
||||
'/providers/:id',
|
||||
// #swagger.tags = ['Admin · Auth Providers']
|
||||
// #swagger.summary = 'Update an SSO provider (admin only)'
|
||||
// #swagger.description = 'Built-ins (google, discord, steam) take only enabled + credentials. Steam has no client id: its secret is a Steam Web API key, and enabling Steam without one stored or given is refused with 400 (STEAM_SIGNIN.md D394).'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||||
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error, or Steam enabled without a Steam Web API key', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
|
||||
@@ -339,20 +339,36 @@ async function totpDisable(req, res) {
|
||||
async function listIdentities(req, res) {
|
||||
try {
|
||||
const rows = await userIdentities.listForUser(req.user.id)
|
||||
return res.json(rows.map((r) => ({ provider: r.provider, email: r.email, linked_at: r.created_at })))
|
||||
// `id` lets one identity be unlinked when a provider has several (a player
|
||||
// may link more than one Steam account). `subject` is the user's OWN external
|
||||
// id — for Steam, the SteamID64 shown beside the persona name (D394).
|
||||
return res.json(
|
||||
rows.map((r) => ({
|
||||
id: r.id,
|
||||
provider: r.provider,
|
||||
subject: r.subject,
|
||||
email: r.email,
|
||||
display_name: r.display_name || null,
|
||||
avatar_url: r.avatar_url || null,
|
||||
linked_at: r.created_at,
|
||||
})),
|
||||
)
|
||||
} catch (err) {
|
||||
log.error('listIdentities', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Remove a linked SSO identity from the current user's account.
|
||||
// Remove a linked SSO identity from the current user's account: every identity
|
||||
// of that provider, or with `?id=` the one identity named. Only the sign-in goes —
|
||||
// a module's own link made from it (Rust's, D392) is the module's to remove (D393).
|
||||
async function unlinkIdentity(req, res) {
|
||||
const { provider } = req.params
|
||||
const identityId = req.query.id != null ? Number(req.query.id) : null
|
||||
try {
|
||||
const removed = await userIdentities.unlink(req.user.id, provider)
|
||||
const removed = await userIdentities.unlink(req.user.id, provider, identityId)
|
||||
if (!removed) return res.status(404).json({ message: 'No linked account for that provider.' })
|
||||
await activity.log({ req, action: 'auth.sso.unlink', detail: { provider } })
|
||||
await activity.log({ req, action: 'auth.sso.unlink', detail: { provider, ...(identityId != null ? { identityId } : {}) } })
|
||||
log.info('sso identity unlinked', { provider, id: req.user.id })
|
||||
return res.json({ unlinked: true })
|
||||
} catch (err) {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
// expect. Mounted at /me by auth/index.js, so paths below are /auth/me/account*.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
const { body, param, query } = require('express-validator')
|
||||
|
||||
const account = require('./account.controller')
|
||||
const { requireAuth } = require('../../../auth/session.middleware')
|
||||
@@ -177,10 +177,14 @@ meRouter.delete(
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Unlink an SSO identity (self)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.description = 'Removes every identity of the provider, or with ?id= the one identity named (a user may link several Steam accounts). Only the sign-in identity goes: a game module link made from it stays (STEAM_SIGNIN.md D393).'
|
||||
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||||
// #swagger.parameters['id'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'One identity to unlink (its id from GET /auth/me/account/identities).' }
|
||||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('provider').matches(/^[a-z0-9-]+$/),
|
||||
query('id').optional().isInt({ min: 1 }),
|
||||
validate,
|
||||
account.unlinkIdentity,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// ── SSO (OAuth2 / OIDC) controller ─────────────────────────────────────────
|
||||
//
|
||||
// Drives the redirect flow for built-in (Google, Discord) and custom providers:
|
||||
// Drives the redirect flow for built-in (Google, Discord, Steam) and custom
|
||||
// providers. Steam is OpenID 2.0 rather than OAuth2 (STEAM_SIGNIN.md §3); the
|
||||
// difference is confined to readCallback() and the provider class:
|
||||
// GET /auth/providers → public discovery (enabled + valid providers)
|
||||
// GET /auth/sso/:provider/start → begin login (redirect to the IdP)
|
||||
// GET /auth/sso/:provider/link → begin account linking (requireAuth)
|
||||
@@ -17,6 +19,8 @@ const authProviders = require('../../../model/authProviders/authProviders.model'
|
||||
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
const registry = require('../../../auth/providers/registry')
|
||||
const SteamProvider = require('../../../auth/providers/steam.provider')
|
||||
const identityHooks = require('../../../auth/identityHooks')
|
||||
const sessionService = require('../../../auth/session.service')
|
||||
const mobileBridge = require('../../../model/mobileAuthBridge/mobileAuthBridge.model')
|
||||
const ssoState = require('../../../auth/ssoState')
|
||||
@@ -148,20 +152,43 @@ async function beginFlow(req, res, mode) {
|
||||
const start = (req, res) => beginFlow(req, res, 'login')
|
||||
const linkStart = (req, res) => beginFlow(req, res, 'link')
|
||||
|
||||
// The callback's own answer from the IdP, read the way its protocol writes it.
|
||||
// OAuth2/OIDC sends `code` (or `error`); Steam's OpenID 2.0 sends `openid.*`
|
||||
// fields, with `openid.mode=cancel` when the person declined (STEAM_SIGNIN.md §3).
|
||||
// Our `state` arrives as a query parameter either way — for Steam because it
|
||||
// rides inside the `return_to` we sent.
|
||||
function readCallback(req, providerId) {
|
||||
const q = req.query || {}
|
||||
if (registry.isOpenId(providerId)) {
|
||||
const mode = typeof q['openid.mode'] === 'string' ? q['openid.mode'] : ''
|
||||
return { state: q.state, denied: mode === 'cancel' || mode === 'error', present: mode === 'id_res' }
|
||||
}
|
||||
return { state: q.state, denied: Boolean(q.error), present: Boolean(q.code), error: q.error }
|
||||
}
|
||||
|
||||
// What the provider's handleCallback needs. Steam takes the whole query, and its
|
||||
// `return_to` must match what we sent (the callback URL plus this very state)
|
||||
// byte for byte; OAuth2 takes the code and the PKCE verifier.
|
||||
function callbackParams(req, providerId, tx, answer) {
|
||||
const redirectUri = redirectUriFor(req, providerId)
|
||||
if (registry.isOpenId(providerId)) return { query: req.query, redirectUri, state: answer.state }
|
||||
return { code: req.query.code, redirectUri, codeVerifier: tx.verifier }
|
||||
}
|
||||
|
||||
// GET /auth/sso/:provider/callback
|
||||
async function callback(req, res) {
|
||||
const providerId = req.params.provider
|
||||
const txToken = req.cookies && req.cookies[ssoState.TX_COOKIE]
|
||||
const { code, state, error: oauthError } = req.query
|
||||
const answer = readCallback(req, providerId)
|
||||
// The tx cookie is single-use — clear it no matter the outcome.
|
||||
res.clearCookie(ssoState.TX_COOKIE, token.cookieOptions(req))
|
||||
|
||||
if (oauthError) {
|
||||
log.warn('sso callback: provider returned error', { provider: providerId, error: String(oauthError).slice(0, 60) })
|
||||
if (answer.denied) {
|
||||
log.warn('sso callback: provider returned error', { provider: providerId, error: String(answer.error || 'cancel').slice(0, 60) })
|
||||
return res.redirect(loginError('denied'))
|
||||
}
|
||||
const tx = ssoState.verifyTx(txToken, state)
|
||||
if (!tx || tx.provider !== providerId || !code) {
|
||||
const tx = ssoState.verifyTx(txToken, answer.state)
|
||||
if (!tx || tx.provider !== providerId || !answer.present) {
|
||||
log.warn('sso callback: bad state', { provider: providerId })
|
||||
return res.redirect(loginError('bad_state'))
|
||||
}
|
||||
@@ -175,20 +202,34 @@ async function callback(req, res) {
|
||||
return res.redirect(failFor('unavailable'))
|
||||
}
|
||||
const provider = registry.instantiate(row)
|
||||
const profile = await provider.handleCallback({
|
||||
code,
|
||||
redirectUri: redirectUriFor(req, providerId),
|
||||
codeVerifier: tx.verifier,
|
||||
})
|
||||
const profile = await provider.handleCallback(callbackParams(req, providerId, tx, answer))
|
||||
if (tx.mode === 'link') return finishLink(req, res, providerId, tx, profile)
|
||||
if (tx.mode === 'mobile') return finishMobileLogin(req, res, providerId, row.kind, tx, profile)
|
||||
return finishLogin(req, res, providerId, row.kind, tx, profile)
|
||||
} catch (err) {
|
||||
// A Steam assertion that failed one of the §3 checks is a refused sign-in, not
|
||||
// a server fault: it is what a replayed, forged or tampered callback looks like.
|
||||
if (err instanceof SteamProvider.SteamAssertionError) {
|
||||
log.warn('sso callback: steam assertion refused', { provider: providerId, reason: err.reason })
|
||||
return res.redirect(failFor('bad_state'))
|
||||
}
|
||||
log.error('sso callback', err)
|
||||
return res.redirect(failFor('error'))
|
||||
}
|
||||
}
|
||||
|
||||
// A profile's name and avatar written back onto an identity the user already
|
||||
// holds, when the provider reports them (Steam, at every sign-in). Decoration:
|
||||
// a failure here is logged and never stops the sign-in it rides on.
|
||||
async function refreshIdentityProfile(identity, profile) {
|
||||
if (!identity || !(profile.displayName || profile.avatarUrl)) return
|
||||
try {
|
||||
await userIdentities.refreshProfile(identity.id, { displayName: profile.displayName, avatarUrl: profile.avatarUrl })
|
||||
} catch (err) {
|
||||
log.warn('sso: could not refresh a linked identity profile', { provider: identity.provider, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-provision a `player` from an SSO profile when no identity is linked yet
|
||||
// and registration allows SSO sign-up. Derives a unique username (reserved-name
|
||||
// safe) with a bounded retry against the UNIQUE index, captures the provider
|
||||
@@ -288,10 +329,12 @@ async function finishLogin(req, res, providerId, kind, tx, profile) {
|
||||
if (identity) {
|
||||
user = await users.getById(identity.user_id)
|
||||
if (!user) return res.redirect(loginError('not_linked', portal))
|
||||
await refreshIdentityProfile(identity, profile)
|
||||
} else {
|
||||
// Unknown identity: auto-provision only if registration opts into SSO sign-up.
|
||||
// Unknown identity: auto-provision only if registration opts into SSO sign-up,
|
||||
// and never through a provider that signs nobody up (Steam, D395).
|
||||
const mode = await settings.getRegistrationMode()
|
||||
if (mode !== 'sso' && mode !== 'both') {
|
||||
if ((mode !== 'sso' && mode !== 'both') || registry.neverProvisions(kind)) {
|
||||
log.warn('sso login refused: no linked account', { provider: providerId })
|
||||
return res.redirect(loginError('not_linked', portal))
|
||||
}
|
||||
@@ -397,7 +440,7 @@ async function requireValidBridgeSession(res, tx, providerId) {
|
||||
// Resolve the linked user for a mobile SSO login (link-only, with opt-in
|
||||
// provisioning when registration mode allows it). On refusal, sends the redirect
|
||||
// and returns null.
|
||||
async function resolveMobileSsoUser(req, res, sess, providerId, profile) {
|
||||
async function resolveMobileSsoUser(req, res, sess, providerId, kind, profile) {
|
||||
const identity = await userIdentities.findByProviderSubject(providerId, profile.subject)
|
||||
if (identity) {
|
||||
const user = await users.getById(identity.user_id)
|
||||
@@ -405,10 +448,11 @@ async function resolveMobileSsoUser(req, res, sess, providerId, profile) {
|
||||
res.redirect(appError(sess, 'not_linked'))
|
||||
return null
|
||||
}
|
||||
await refreshIdentityProfile(identity, profile)
|
||||
return user
|
||||
}
|
||||
const mode = await settings.getRegistrationMode()
|
||||
if (mode !== 'sso' && mode !== 'both') {
|
||||
if ((mode !== 'sso' && mode !== 'both') || registry.neverProvisions(kind)) {
|
||||
log.warn('mobile sso login refused: no linked account', { provider: providerId })
|
||||
res.redirect(appError(sess, 'not_linked'))
|
||||
return null
|
||||
@@ -425,7 +469,7 @@ async function finishMobileLogin(req, res, providerId, kind, tx, profile) {
|
||||
const sess = await requireValidBridgeSession(res, tx, providerId)
|
||||
if (!sess) return
|
||||
|
||||
const user = await resolveMobileSsoUser(req, res, sess, providerId, profile)
|
||||
const user = await resolveMobileSsoUser(req, res, sess, providerId, kind, profile)
|
||||
if (!user) return
|
||||
|
||||
if (user.status && user.status !== 'active') {
|
||||
@@ -555,11 +599,38 @@ async function finishLink(req, res, providerId, tx, profile) {
|
||||
if (existing && existing.user_id !== userId) {
|
||||
return res.redirect(accountError('in_use', portal)) // external identity belongs to another account
|
||||
}
|
||||
if (!existing) {
|
||||
await userIdentities.link({ userId, provider: providerId, subject: profile.subject, email: profile.email })
|
||||
await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } })
|
||||
log.info('sso account linked', { provider: providerId, userId })
|
||||
if (existing) {
|
||||
// Already theirs: linking twice is not an error, and it is a fresh look at
|
||||
// the name and avatar.
|
||||
await refreshIdentityProfile(existing, profile)
|
||||
return res.redirect(`${accountPath(portal)}?linked=${providerId}`)
|
||||
}
|
||||
|
||||
// Modules may refuse before anything is written (API 1.17.0) — Rust, for a
|
||||
// Steam id that is already another account's Rust link (D396). Only ever a
|
||||
// refusal: this user is already signed in, and this decides a link, not a session.
|
||||
const check = await identityHooks.checkLink({ userId, provider: providerId, subject: profile.subject })
|
||||
if (!check.ok) {
|
||||
await activity.log({ req, userId, action: 'auth.sso.link.refused', detail: { provider: providerId, module: check.module, reason: check.reason } })
|
||||
return res.redirect(accountError(check.reason, portal))
|
||||
}
|
||||
|
||||
await userIdentities.link({
|
||||
userId,
|
||||
provider: providerId,
|
||||
subject: profile.subject,
|
||||
email: profile.email,
|
||||
displayName: profile.displayName || null,
|
||||
avatarUrl: profile.avatarUrl || null,
|
||||
})
|
||||
await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } })
|
||||
log.info('sso account linked', { provider: providerId, userId })
|
||||
await identityHooks.notifyLinked({
|
||||
userId,
|
||||
provider: providerId,
|
||||
subject: profile.subject,
|
||||
displayName: profile.displayName || null,
|
||||
})
|
||||
return res.redirect(`${accountPath(portal)}?linked=${providerId}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -52,10 +52,13 @@ ssoRouter.get(
|
||||
'/sso/:provider/callback',
|
||||
// #swagger.tags = ['Auth · SSO']
|
||||
// #swagger.summary = 'OAuth redirect target — completes login or linking'
|
||||
// #swagger.description = 'The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked).'
|
||||
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id (e.g. google, discord).' }
|
||||
// #swagger.parameters['code'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth authorization code.' }
|
||||
// #swagger.parameters['state'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth state (matched against the tx cookie).' }
|
||||
// #swagger.description = 'The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked). Steam (OpenID 2.0) sends openid.* fields instead of code; the server verifies them with Steam (check_authentication) before anything else, and Steam never signs anybody up (STEAM_SIGNIN.md D395). A module may refuse a link (link_error=in_use or refused; MODULE_API 1.17.0).'
|
||||
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id (e.g. google, discord, steam).' }
|
||||
// #swagger.parameters['code'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth authorization code (OAuth2/OIDC providers).' }
|
||||
// #swagger.parameters['state'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth state (matched against the tx cookie). For Steam it rides inside openid.return_to and comes back as this parameter.' }
|
||||
// #swagger.parameters['error'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth error (the user declined, or the IdP refused).' }
|
||||
// #swagger.parameters['openid.mode'] = { in: 'query', required: false, schema: { type: 'string', enum: ['id_res', 'cancel'] }, description: 'Steam only: id_res with an assertion, or cancel. The other openid.* fields of the assertion (ns, op_endpoint, claimed_id, identity, return_to, response_nonce, assoc_handle, signed, sig) are passed back to Steam to verify.' }
|
||||
// #swagger.parameters['openid.claimed_id'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Steam only: https://steamcommunity.com/openid/id/<SteamID64>.' }
|
||||
/* #swagger.responses[302] = { description: 'Redirect into /admin on success, or back to login/account with an error code' } */
|
||||
ctrl.callback,
|
||||
)
|
||||
|
||||
@@ -13089,7 +13089,7 @@
|
||||
"Auth · Me"
|
||||
],
|
||||
"summary": "Unlink an SSO identity (self)",
|
||||
"description": "",
|
||||
"description": "Removes every identity of the provider, or with ?id= the one identity named (a user may link several Steam accounts). Only the sign-in identity goes: a game module link made from it stays (STEAM_SIGNIN.md D393).",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
@@ -13099,6 +13099,15 @@
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Provider id."
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "One identity to unlink (its id from GET /auth/me/account/identities)."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -13113,7 +13122,14 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
@@ -15467,7 +15483,7 @@
|
||||
"Auth · SSO"
|
||||
],
|
||||
"summary": "OAuth redirect target — completes login or linking",
|
||||
"description": "The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked).",
|
||||
"description": "The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked). Steam (OpenID 2.0) sends openid.* fields instead of code; the server verifies them with Steam (check_authentication) before anything else, and Steam never signs anybody up (STEAM_SIGNIN.md D395). A module may refuse a link (link_error=in_use or refused; MODULE_API 1.17.0).",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
@@ -15476,14 +15492,7 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Provider id (e.g. google, discord)."
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
"description": "Provider id (e.g. google, discord, steam)."
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
@@ -15492,7 +15501,7 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "OAuth authorization code."
|
||||
"description": "OAuth authorization code (OAuth2/OIDC providers)."
|
||||
},
|
||||
{
|
||||
"name": "state",
|
||||
@@ -15501,7 +15510,50 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "OAuth state (matched against the tx cookie)."
|
||||
"description": "OAuth state (matched against the tx cookie). For Steam it rides inside openid.return_to and comes back as this parameter."
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "OAuth error (the user declined, or the IdP refused)."
|
||||
},
|
||||
{
|
||||
"name": "openid.mode",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"id_res",
|
||||
"cancel"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Steam only: id_res with an assertion, or cancel. The other openid.* fields of the assertion (ns, op_endpoint, claimed_id, identity, return_to, response_nonce, assoc_handle, signed, sig) are passed back to Steam to verify."
|
||||
},
|
||||
{
|
||||
"name": "openid.claimed_id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Steam only: https://steamcommunity.com/openid/id/<SteamID64>."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -19827,7 +19879,7 @@
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2')."
|
||||
"example": "Icon hint — the provider kind ('google' | 'discord' | 'steam' | 'oidc' | 'oauth2')."
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
@@ -19908,6 +19960,7 @@
|
||||
"example": [
|
||||
"google",
|
||||
"discord",
|
||||
"steam",
|
||||
"oidc",
|
||||
"oauth2"
|
||||
],
|
||||
@@ -19965,7 +20018,7 @@
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Whether a client secret is stored (the secret itself is never returned)."
|
||||
"example": "Whether a client secret is stored (the secret itself is never returned). For steam, the Steam Web API key it cannot be enabled without."
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
@@ -20047,7 +20100,7 @@
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "True for the fixed google/discord providers."
|
||||
"example": "True for the fixed google/discord/steam providers."
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
@@ -24740,6 +24793,23 @@
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Identity id, for unlinking one of several (DELETE ...?id=)."
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 12
|
||||
}
|
||||
}
|
||||
},
|
||||
"provider": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -24774,6 +24844,65 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"subject": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The user’s own external id at the provider; for steam, the 17-digit SteamID64."
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "76561197960287930"
|
||||
}
|
||||
}
|
||||
},
|
||||
"display_name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Persona name the provider reported (steam only)."
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "CoolGuy99"
|
||||
}
|
||||
}
|
||||
},
|
||||
"avatar_url": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"example": "uri"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Avatar on the provider’s CDN (steam only)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"linked_at": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -294,7 +294,7 @@ const doc = {
|
||||
name: { type: 'string', example: 'Google' },
|
||||
icon: {
|
||||
type: 'string',
|
||||
description: "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2').",
|
||||
description: "Icon hint — the provider kind ('google' | 'discord' | 'steam' | 'oidc' | 'oauth2').",
|
||||
example: 'google',
|
||||
},
|
||||
loginUrl: {
|
||||
@@ -313,17 +313,17 @@ const doc = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'okta' },
|
||||
kind: { type: 'string', enum: ['google', 'discord', 'oidc', 'oauth2'], example: 'oidc' },
|
||||
kind: { type: 'string', enum: ['google', 'discord', 'steam', 'oidc', 'oauth2'], example: 'oidc' },
|
||||
name: { type: 'string', example: 'Okta' },
|
||||
enabled: { type: 'boolean', example: true },
|
||||
clientId: { type: 'string' },
|
||||
hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned).', example: true },
|
||||
hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned). For steam, the Steam Web API key it cannot be enabled without.', example: true },
|
||||
authorizeUrl: { type: 'string', format: 'uri' },
|
||||
tokenUrl: { type: 'string', format: 'uri' },
|
||||
userinfoUrl: { type: 'string', format: 'uri' },
|
||||
scopes: { type: 'string', example: 'openid email profile' },
|
||||
priority: { type: 'integer', example: 10 },
|
||||
builtin: { type: 'boolean', description: 'True for the fixed google/discord providers.', example: false },
|
||||
builtin: { type: 'boolean', description: 'True for the fixed google/discord/steam providers.', example: false },
|
||||
health: { $ref: '#/components/schemas/ProviderHealth' },
|
||||
},
|
||||
},
|
||||
@@ -961,8 +961,12 @@ const doc = {
|
||||
LinkedIdentity: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer', description: 'Identity id, for unlinking one of several (DELETE ...?id=).', example: 12 },
|
||||
provider: { type: 'string', example: 'google' },
|
||||
email: { type: 'string', format: 'email', nullable: true, example: 'user@example.com' },
|
||||
subject: { type: 'string', description: 'The user’s own external id at the provider; for steam, the 17-digit SteamID64.', example: '76561197960287930' },
|
||||
display_name: { type: 'string', nullable: true, description: 'Persona name the provider reported (steam only).', example: 'CoolGuy99' },
|
||||
avatar_url: { type: 'string', format: 'uri', nullable: true, description: 'Avatar on the provider’s CDN (steam only).' },
|
||||
linked_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -112,7 +112,7 @@ test('the version a module declares against satisfies ^1.10.0', () => {
|
||||
// 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the
|
||||
// assertion that says it does. The number itself is pinned in version.js.
|
||||
assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION)
|
||||
assert.equal(MODULE_API_VERSION, '1.16.0')
|
||||
assert.equal(MODULE_API_VERSION, '1.17.0')
|
||||
})
|
||||
|
||||
test('a module registers actions, budgets, leases and option sources', () => {
|
||||
|
||||
@@ -519,8 +519,11 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
|
||||
// throws until Phase 7 builds the channel — which is why it has to be in this
|
||||
// list now: a member of a declared version that were absent would make
|
||||
// MODULE_API_VERSION a lie, and this test is what says so.
|
||||
// API 1.17.0 added `identities` (STEAM_SIGNIN.md §4): READ only, one provider
|
||||
// at a time. Linking stays core's, and a module takes part through the two
|
||||
// hooks on `api`, never by writing an identity itself.
|
||||
assert.deepEqual(probe.keys, [
|
||||
'activity', 'auth', 'db', 'events', 'express', 'inbox', 'log', 'middleware', 'moduleId', 'paths',
|
||||
'activity', 'auth', 'db', 'events', 'express', 'identities', 'inbox', 'log', 'middleware', 'moduleId', 'paths',
|
||||
'posts', 'push', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator',
|
||||
])
|
||||
// is core's limiter FACTORY, not a limiter: a module states its own
|
||||
|
||||
@@ -28,11 +28,11 @@ test('validateConfig: custom OIDC also needs the endpoint URLs', () => {
|
||||
assert.deepEqual(registry.validateConfig(noUrls).missing, ['authorize_url', 'token_url', 'userinfo_url'])
|
||||
})
|
||||
|
||||
test('listConfigured always includes both built-ins with health', async () => {
|
||||
test('listConfigured always includes every built-in with health', async () => {
|
||||
authProvidersModel.list = async () => [] // no rows yet
|
||||
const out = await registry.listConfigured()
|
||||
const ids = out.map((p) => p.id)
|
||||
assert.deepEqual(ids, ['google', 'discord'])
|
||||
assert.deepEqual(ids, ['google', 'discord', 'steam'])
|
||||
assert.equal(out[0].builtin, true)
|
||||
assert.equal(out[0].enabled, 0)
|
||||
assert.equal(out[0].health.valid, false) // unconfigured
|
||||
|
||||
@@ -131,7 +131,7 @@ test('link mode → identity linked to the acting user, redirect to account', as
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(makeReq(tx), res)
|
||||
|
||||
assert.deepEqual(linkArgs, { userId: 5, provider: 'google', subject: 'sub-1', email: 'alice@example.com' })
|
||||
assert.deepEqual(linkArgs, { userId: 5, provider: 'google', subject: 'sub-1', email: 'alice@example.com', displayName: null, avatarUrl: null })
|
||||
assert.equal(res.redirectedTo, '/admin/account?linked=google')
|
||||
assert.equal(logged.at(-1).action, 'auth.sso.link')
|
||||
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'linking does not start a session')
|
||||
|
||||
433
server/test/steamSignin.test.js
Normal file
433
server/test/steamSignin.test.js
Normal file
@@ -0,0 +1,433 @@
|
||||
// Sign in with Steam (docs/website/STEAM_SIGNIN.md): the OpenID 2.0 provider's
|
||||
// checks (§3), the callback through the real SSO controller, D394 (key required),
|
||||
// D395 (never signs up) and the module hooks behind D392/D396 (MODULE_API 1.17.0).
|
||||
// Steam itself is a stubbed global fetch; no request leaves the process.
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
|
||||
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
process.env.APP_BASE_URL = 'https://site.example'
|
||||
|
||||
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const SteamProvider = require('../src/auth/providers/steam.provider')
|
||||
const registry = require('../src/auth/providers/registry')
|
||||
const ssoCtrl = require('../src/router/v1/auth/sso.controller')
|
||||
const ssoState = require('../src/auth/ssoState')
|
||||
const token = require('../src/auth/token')
|
||||
const identityHooks = require('../src/auth/identityHooks')
|
||||
const loader = require('../src/modules/loader')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const activity = require('../src/model/activity/activity.model')
|
||||
const authProviders = require('../src/model/authProviders/authProviders.model')
|
||||
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const mobileBridge = require('../src/model/mobileAuthBridge/mobileAuthBridge.model')
|
||||
const providersCtrl = require('../src/router/v1/admin/authProviders.controller')
|
||||
const accountCtrl = require('../src/router/v1/auth/account.controller')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const STEAM_ID = '76561197960287930'
|
||||
const CALLBACK = 'https://site.example/api/v1/auth/sso/steam/callback'
|
||||
const STEAM_ROW = { id: 'steam', kind: 'steam', name: 'Steam', enabled: 1, client_id: null, client_secret_enc: 'enc', client_secret: 'WEBAPIKEY' }
|
||||
|
||||
// ── A stand-in for steamcommunity.com and api.steampowered.com ──────────────
|
||||
|
||||
const realFetch = global.fetch
|
||||
let steam // { valid, verifyStatus, verifyThrows, summary, summaryStatus, calls }
|
||||
function installSteam(overrides = {}) {
|
||||
steam = {
|
||||
valid: true,
|
||||
verifyStatus: 200,
|
||||
verifyThrows: false,
|
||||
summary: { steamid: STEAM_ID, personaname: 'CoolGuy99', avatarfull: 'https://avatars.steamstatic.com/abc_full.jpg' },
|
||||
summaryStatus: 200,
|
||||
calls: [],
|
||||
...overrides,
|
||||
}
|
||||
global.fetch = async (url, init = {}) => {
|
||||
const href = String(url)
|
||||
steam.calls.push({ href, init })
|
||||
if (href === SteamProvider.OP_ENDPOINT) {
|
||||
if (steam.verifyThrows) throw new Error('ECONNRESET')
|
||||
const body = new URLSearchParams(init.body)
|
||||
steam.lastVerifyBody = body
|
||||
return {
|
||||
ok: steam.verifyStatus === 200,
|
||||
status: steam.verifyStatus,
|
||||
text: async () => `ns:http://specs.openid.net/auth/2.0\nis_valid:${steam.valid}\n`,
|
||||
}
|
||||
}
|
||||
if (href.startsWith('https://api.steampowered.com/')) {
|
||||
return {
|
||||
ok: steam.summaryStatus === 200,
|
||||
status: steam.summaryStatus,
|
||||
json: async () => ({ response: { players: steam.summary ? [steam.summary] : [] } }),
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected fetch ${href}`)
|
||||
}
|
||||
}
|
||||
afterEach(() => { global.fetch = realFetch })
|
||||
|
||||
// What Steam appends to our return_to after a successful sign-in.
|
||||
function assertion(state, overrides = {}) {
|
||||
const claimed = `https://steamcommunity.com/openid/id/${STEAM_ID}`
|
||||
return {
|
||||
state,
|
||||
'openid.ns': 'http://specs.openid.net/auth/2.0',
|
||||
'openid.mode': 'id_res',
|
||||
'openid.op_endpoint': SteamProvider.OP_ENDPOINT,
|
||||
'openid.claimed_id': claimed,
|
||||
'openid.identity': claimed,
|
||||
'openid.return_to': SteamProvider.returnToFor(CALLBACK, state),
|
||||
'openid.response_nonce': '2026-10-19T12:00:00Zabc',
|
||||
'openid.assoc_handle': '1234567890',
|
||||
'openid.signed': 'signed,op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle',
|
||||
'openid.sig': 'c2lnbmF0dXJl',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ── The provider (§3) ───────────────────────────────────────────────────────
|
||||
|
||||
test('the authorization URL is an OpenID 2.0 checkid_setup with our state inside return_to', () => {
|
||||
const url = new URL(new SteamProvider({}).getAuthorizationUrl('nonce-1', { redirectUri: CALLBACK }))
|
||||
assert.equal(url.origin + url.pathname, SteamProvider.OP_ENDPOINT)
|
||||
assert.equal(url.searchParams.get('openid.mode'), 'checkid_setup')
|
||||
assert.equal(url.searchParams.get('openid.return_to'), `${CALLBACK}?state=nonce-1`)
|
||||
assert.equal(url.searchParams.get('openid.realm'), 'https://site.example')
|
||||
assert.equal(url.searchParams.get('openid.claimed_id'), 'http://specs.openid.net/auth/2.0/identifier_select')
|
||||
assert.equal(url.searchParams.get('client_id'), null, 'Steam has no client id')
|
||||
})
|
||||
|
||||
test('parseClaimedId accepts only a 17-digit id on steamcommunity.com', () => {
|
||||
assert.equal(SteamProvider.parseClaimedId(`https://steamcommunity.com/openid/id/${STEAM_ID}`), STEAM_ID)
|
||||
assert.equal(SteamProvider.parseClaimedId('http://steamcommunity.com/openid/id/76561197960287930'), null)
|
||||
assert.equal(SteamProvider.parseClaimedId('https://steamcommunity.com/openid/id/123'), null)
|
||||
assert.equal(SteamProvider.parseClaimedId('https://evil.example/openid/id/76561197960287930'), null)
|
||||
assert.equal(SteamProvider.parseClaimedId(`https://steamcommunity.com/openid/id/${STEAM_ID}/x`), null)
|
||||
assert.equal(SteamProvider.parseClaimedId(undefined), null)
|
||||
})
|
||||
|
||||
test('a valid assertion is verified with Steam and yields the id, persona name and avatar', async () => {
|
||||
installSteam()
|
||||
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
|
||||
const profile = await p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' })
|
||||
assert.deepEqual(profile, {
|
||||
subject: STEAM_ID, email: null, emailVerified: false, name: 'CoolGuy99',
|
||||
displayName: 'CoolGuy99', avatarUrl: 'https://avatars.steamstatic.com/abc_full.jpg',
|
||||
})
|
||||
// The verification echoed every openid field back with mode check_authentication,
|
||||
// and did not forward our own `state`.
|
||||
assert.equal(steam.lastVerifyBody.get('openid.mode'), 'check_authentication')
|
||||
assert.equal(steam.lastVerifyBody.get('openid.sig'), 'c2lnbmF0dXJl')
|
||||
assert.equal(steam.lastVerifyBody.get('state'), null)
|
||||
})
|
||||
|
||||
for (const [label, overrides, state] of [
|
||||
['a wrong op_endpoint', { 'openid.op_endpoint': 'https://evil.example/openid/login' }, 's1'],
|
||||
['a return_to that is not ours', { 'openid.return_to': 'https://evil.example/cb?state=s1' }, 's1'],
|
||||
['a claimed id that is not a Steam id', { 'openid.claimed_id': 'https://steamcommunity.com/openid/id/1', 'openid.identity': 'https://steamcommunity.com/openid/id/1' }, 's1'],
|
||||
['an identity that differs from the claimed id', { 'openid.identity': 'https://steamcommunity.com/openid/id/76561197960287931' }, 's1'],
|
||||
['mode cancel', { 'openid.mode': 'cancel' }, 's1'],
|
||||
]) {
|
||||
test(`${label} is refused before Steam is even asked`, async () => {
|
||||
installSteam()
|
||||
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
|
||||
await assert.rejects(
|
||||
p.handleCallback({ query: assertion(state, overrides), redirectUri: CALLBACK, state }),
|
||||
SteamProvider.SteamAssertionError,
|
||||
)
|
||||
assert.equal(steam.calls.length, 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('an assertion replayed into another sign-in fails: its return_to carries the old state', async () => {
|
||||
installSteam()
|
||||
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
|
||||
// Captured from a sign-in whose state was s-old, replayed against a tx whose state is s-new.
|
||||
const replay = { ...assertion('s-old'), state: 's-new' }
|
||||
await assert.rejects(p.handleCallback({ query: replay, redirectUri: CALLBACK, state: 's-new' }), { reason: 'return_to' })
|
||||
})
|
||||
|
||||
test('check_authentication saying is_valid:false refuses the sign-in', async () => {
|
||||
installSteam({ valid: false })
|
||||
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
|
||||
await assert.rejects(p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' }), { reason: 'not_valid' })
|
||||
})
|
||||
|
||||
test('Steam unreachable for verification is a refusal, never a pass', async () => {
|
||||
installSteam({ verifyThrows: true })
|
||||
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
|
||||
await assert.rejects(p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' }), { reason: 'not_valid' })
|
||||
installSteam({ verifyStatus: 503 })
|
||||
await assert.rejects(p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' }), { reason: 'not_valid' })
|
||||
})
|
||||
|
||||
test('a failed Web API lookup still signs in, with the bare id (D394)', async () => {
|
||||
installSteam({ summaryStatus: 403 })
|
||||
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
|
||||
const profile = await p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' })
|
||||
assert.equal(profile.subject, STEAM_ID)
|
||||
assert.equal(profile.displayName, null)
|
||||
assert.equal(profile.avatarUrl, null)
|
||||
})
|
||||
|
||||
test('an avatar that is not on a Steam CDN is dropped', async () => {
|
||||
installSteam({ summary: { steamid: STEAM_ID, personaname: 'X', avatarfull: 'https://evil.example/a.jpg' } })
|
||||
const profile = await new SteamProvider({ clientSecret: 'K' }).handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' })
|
||||
assert.equal(profile.displayName, 'X')
|
||||
assert.equal(profile.avatarUrl, null)
|
||||
})
|
||||
|
||||
// ── Registry and Admin → Auth (D394) ────────────────────────────────────────
|
||||
|
||||
test('Steam is a built-in that needs only a Web API key', () => {
|
||||
assert.ok(registry.isBuiltin('steam'))
|
||||
assert.deepEqual(registry.validateConfig({ kind: 'steam' }), { valid: false, missing: ['api_key'] })
|
||||
assert.deepEqual(registry.validateConfig({ kind: 'steam', client_secret_enc: 'e' }), { valid: true, missing: [] })
|
||||
assert.equal(registry.neverProvisions('steam'), true)
|
||||
assert.equal(registry.neverProvisions('google'), false)
|
||||
})
|
||||
|
||||
function mockRes() {
|
||||
return {
|
||||
statusCode: 200, redirectedTo: null, cookies: {}, cleared: [],
|
||||
status(c) { this.statusCode = c; return this },
|
||||
json(b) { this.body = b; return this },
|
||||
redirect(u) { this.redirectedTo = u; return this },
|
||||
cookie(n, v) { this.cookies[n] = v; return this },
|
||||
clearCookie(n) { this.cleared.push(n); return this },
|
||||
}
|
||||
}
|
||||
|
||||
test('enabling Steam with no key is refused with a sentence; with one it saves', async () => {
|
||||
let saved = null
|
||||
authProviders.get = async () => null
|
||||
authProviders.save = async (id, fields) => { saved = { id, ...fields }; return { id, kind: 'steam', name: 'Steam', enabled: 1, client_secret_enc: 'e' } }
|
||||
activity.log = async () => {}
|
||||
|
||||
const refused = mockRes()
|
||||
await providersCtrl.update({ params: { id: 'steam' }, body: { enabled: true }, user: { username: 'admin' } }, refused)
|
||||
assert.equal(refused.statusCode, 400)
|
||||
assert.match(refused.body.message, /Steam Web API key/)
|
||||
assert.equal(saved, null)
|
||||
|
||||
const ok = mockRes()
|
||||
await providersCtrl.update({ params: { id: 'steam' }, body: { enabled: true, secret: 'WEBAPIKEY', clientId: 'ignored' }, user: { username: 'admin' } }, ok)
|
||||
assert.equal(ok.statusCode, 200)
|
||||
assert.equal(saved.kind, 'steam')
|
||||
assert.equal(saved.secret, 'WEBAPIKEY')
|
||||
assert.equal(saved.clientId, undefined, 'Steam keeps no client id')
|
||||
assert.equal(ok.body.hasSecret, true)
|
||||
})
|
||||
|
||||
test('re-enabling Steam with a key already stored needs no new one', async () => {
|
||||
authProviders.get = async () => ({ id: 'steam', kind: 'steam', client_secret_enc: 'e' })
|
||||
authProviders.save = async () => ({ id: 'steam', kind: 'steam', name: 'Steam', enabled: 1, client_secret_enc: 'e' })
|
||||
activity.log = async () => {}
|
||||
const res = mockRes()
|
||||
await providersCtrl.update({ params: { id: 'steam' }, body: { enabled: true }, user: { username: 'admin' } }, res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
})
|
||||
|
||||
// ── The callback through the real controller ────────────────────────────────
|
||||
|
||||
let logged
|
||||
let linkArgs
|
||||
let refreshed
|
||||
let created
|
||||
let hooks
|
||||
beforeEach(() => {
|
||||
logged = []
|
||||
linkArgs = null
|
||||
refreshed = null
|
||||
created = null
|
||||
hooks = { onIdentityLinking: [], onIdentityLinked: [] }
|
||||
activity.log = async (evt) => { logged.push(evt) }
|
||||
authProviders.getWithSecret = async () => ({ ...STEAM_ROW })
|
||||
registry.instantiate = (row) => new SteamProvider({ ...row, clientSecret: row.client_secret })
|
||||
userIdentities.findByProviderSubject = async () => null
|
||||
userIdentities.link = async (args) => { linkArgs = args; return 1 }
|
||||
userIdentities.refreshProfile = async (id, p) => { refreshed = { id, ...p }; return 1 }
|
||||
users.getById = async (id) => ({ id, username: 'alice', role: 'player', status: 'active' })
|
||||
users.recordLogin = async () => {}
|
||||
users.createUser = async (args) => { created = args; return { id: 99, ...args } }
|
||||
settings.getRegistrationMode = async () => 'both' // SSO sign-up ON: Steam must still refuse
|
||||
loader.identityHooks = (name) => hooks[name]
|
||||
installSteam()
|
||||
})
|
||||
|
||||
function steamReq(tx, overrides = {}) {
|
||||
return {
|
||||
params: { provider: 'steam' },
|
||||
cookies: { [ssoState.TX_COOKIE]: tx.txToken },
|
||||
query: assertion(tx.nonce, overrides),
|
||||
ip: '127.0.0.1', protocol: 'https', get: () => 'site.example', headers: {},
|
||||
}
|
||||
}
|
||||
|
||||
test('a linked Steam account signs in through the one session service, and its name is refreshed', async () => {
|
||||
userIdentities.findByProviderSubject = async (provider, subject) =>
|
||||
(provider === 'steam' && subject === STEAM_ID ? { id: 3, user_id: 7, provider: 'steam' } : null)
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'login', returnTo: '/account' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.ok(res.cookies[token.COOKIE_NAME], 'session cookie set')
|
||||
assert.equal(res.redirectedTo, '/account')
|
||||
assert.equal(logged.at(-1).action, 'auth.sso.login')
|
||||
assert.deepEqual(refreshed, { id: 3, displayName: 'CoolGuy99', avatarUrl: 'https://avatars.steamstatic.com/abc_full.jpg' })
|
||||
})
|
||||
|
||||
test('an unlinked Steam id signs nobody in and creates nobody, even with SSO sign-up on (D395)', async () => {
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'login', returnTo: '/account' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.equal(res.redirectedTo, '/account/login?sso_error=not_linked')
|
||||
assert.equal(created, null)
|
||||
assert.equal(linkArgs, null)
|
||||
assert.equal(res.cookies[token.COOKIE_NAME], undefined)
|
||||
})
|
||||
|
||||
test('the same refusal holds on the mobile bridge (D395)', async () => {
|
||||
mobileBridge.getSession = async () => ({
|
||||
session_id: 'm1', status: 'pending', redirect_uri: 'runicgateway://sso', state: 'app-state',
|
||||
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
||||
})
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'mobile', mobileSessionId: 'm1' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.equal(res.redirectedTo, 'runicgateway://sso?error=not_linked&state=app-state')
|
||||
assert.equal(created, null)
|
||||
})
|
||||
|
||||
test('a forged callback (Steam says is_valid:false) is a refused sign-in, not a server error', async () => {
|
||||
installSteam({ valid: false })
|
||||
userIdentities.findByProviderSubject = async () => ({ id: 3, user_id: 7 })
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'login' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.equal(res.redirectedTo, '/admin/login?sso_error=bad_state')
|
||||
assert.equal(res.cookies[token.COOKIE_NAME], undefined)
|
||||
})
|
||||
|
||||
test('cancelling on steamcommunity.com reads as denied', async () => {
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'login' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback({ ...steamReq(tx), query: { state: tx.nonce, 'openid.mode': 'cancel' } }, res)
|
||||
assert.equal(res.redirectedTo, '/admin/login?sso_error=denied')
|
||||
assert.equal(steam.calls.length, 0)
|
||||
})
|
||||
|
||||
test('a callback with a state that is not the tx is refused before Steam is asked', async () => {
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'login' })
|
||||
const res = mockRes()
|
||||
const req = steamReq(tx)
|
||||
req.query.state = 'tampered'
|
||||
await ssoCtrl.callback(req, res)
|
||||
assert.equal(res.redirectedTo, '/admin/login?sso_error=bad_state')
|
||||
assert.equal(steam.calls.length, 0)
|
||||
})
|
||||
|
||||
// ── Linking, and the module hooks (MODULE_API 1.17.0) ───────────────────────
|
||||
|
||||
test('linking Steam stores the id with its name and avatar, then tells the modules once (D392)', async () => {
|
||||
const told = []
|
||||
hooks.onIdentityLinked = [{ id: 'rust', hook: async (link) => { told.push(link) } }]
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.deepEqual(linkArgs, {
|
||||
userId: 5, provider: 'steam', subject: STEAM_ID, email: null,
|
||||
displayName: 'CoolGuy99', avatarUrl: 'https://avatars.steamstatic.com/abc_full.jpg',
|
||||
})
|
||||
assert.equal(res.redirectedTo, '/account?linked=steam')
|
||||
assert.deepEqual(told, [{ userId: 5, provider: 'steam', subject: STEAM_ID, displayName: 'CoolGuy99' }])
|
||||
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'linking does not start a session')
|
||||
})
|
||||
|
||||
test('a module refusing a link writes nothing and the player reads "in use" (D396)', async () => {
|
||||
const told = []
|
||||
hooks.onIdentityLinking = [{ id: 'rust', hook: async () => ({ ok: false, reason: 'in_use', message: 'held by bob' }) }]
|
||||
hooks.onIdentityLinked = [{ id: 'rust', hook: async (link) => { told.push(link) } }]
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.equal(res.redirectedTo, '/account?link_error=in_use')
|
||||
assert.equal(linkArgs, null)
|
||||
assert.deepEqual(told, [])
|
||||
assert.equal(logged.at(-1).action, 'auth.sso.link.refused')
|
||||
assert.doesNotMatch(res.redirectedTo, /bob/, 'a module\'s own words never reach the URL')
|
||||
})
|
||||
|
||||
test('a module that throws or stalls refuses the link (fails closed)', async () => {
|
||||
hooks.onIdentityLinking = [{ id: 'rust', hook: async () => { throw new Error('db down') } }]
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.equal(res.redirectedTo, '/account?link_error=refused')
|
||||
assert.equal(linkArgs, null)
|
||||
})
|
||||
|
||||
test('a linked hook that fails never undoes the link', async () => {
|
||||
hooks.onIdentityLinked = [{ id: 'rust', hook: async () => { throw new Error('boom') } }]
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.equal(res.redirectedTo, '/account?linked=steam')
|
||||
assert.ok(linkArgs)
|
||||
})
|
||||
|
||||
test('linking a Steam account already yours refreshes it and asks no module', async () => {
|
||||
let asked = false
|
||||
hooks.onIdentityLinking = [{ id: 'rust', hook: async () => { asked = true; return { ok: true } } }]
|
||||
userIdentities.findByProviderSubject = async () => ({ id: 3, user_id: 5, provider: 'steam' })
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.equal(res.redirectedTo, '/account?linked=steam')
|
||||
assert.equal(asked, false)
|
||||
assert.equal(linkArgs, null)
|
||||
assert.equal(refreshed.id, 3)
|
||||
})
|
||||
|
||||
test('checkLink stops at the first refusal and hands a hook a frozen copy', async () => {
|
||||
let seen
|
||||
hooks.onIdentityLinking = [
|
||||
{ id: 'a', hook: async (link) => { seen = link; return { ok: true } } },
|
||||
{ id: 'b', hook: async () => ({ ok: false, reason: 'anything else' }) },
|
||||
{ id: 'c', hook: async () => { throw new Error('never reached') } },
|
||||
]
|
||||
const answer = await identityHooks.checkLink({ userId: 1, provider: 'steam', subject: STEAM_ID, extra: 'x' })
|
||||
assert.deepEqual(answer, { ok: false, reason: 'refused', module: 'b' })
|
||||
assert.ok(Object.isFrozen(seen))
|
||||
assert.deepEqual(Object.keys(seen), ['userId', 'provider', 'subject'])
|
||||
})
|
||||
|
||||
test('with no module hooks a link is checked exactly as before', async () => {
|
||||
assert.deepEqual(await identityHooks.checkLink({ userId: 1, provider: 'google', subject: 's' }), { ok: true })
|
||||
})
|
||||
|
||||
// ── Unlinking one of several (D393 leaves module links alone) ───────────────
|
||||
|
||||
test('unlink with ?id= removes that one identity only', async () => {
|
||||
let args
|
||||
userIdentities.unlink = async (...a) => { args = a; return 1 }
|
||||
const res = mockRes()
|
||||
await accountCtrl.unlinkIdentity({ params: { provider: 'steam' }, query: { id: '12' }, user: { id: 5 } }, res)
|
||||
assert.deepEqual(args, [5, 'steam', 12])
|
||||
assert.deepEqual(res.body, { unlinked: true })
|
||||
})
|
||||
|
||||
test('unlink without an id removes every identity of the provider, as before', async () => {
|
||||
let args
|
||||
userIdentities.unlink = async (...a) => { args = a; return 2 }
|
||||
const res = mockRes()
|
||||
await accountCtrl.unlinkIdentity({ params: { provider: 'google' }, query: {}, user: { id: 5 } }, res)
|
||||
assert.deepEqual(args, [5, 'google', null])
|
||||
})
|
||||
Reference in New Issue
Block a user