Per-host SSO: Node auth endpoints + Redis session (#57)
Adds the /__proxy_auth OIDC flow served on every proxied host: - routes/host_auth.js: /start (PKCE+state, per-host redirect_uri), /callback (exchange, enforce the host allow-list via utils/host_sso.identityAllowed, mint session + set __proxy_sso cookie), /logout. - models/sso_session.js: SsoSession (Redis-backed, TTL'd; read directly by the Lua gate) and HostSsoState (in-flight auth request). - utils/oidc.js: per-host redirect_uri override on buildAuthUrl/exchangeCode. - conf.hostSso (reuses conf.oidc). Allow-list logic unit-tested. Enforcement (Lua gate + nginx location) lands next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+6
-1
@@ -60,10 +60,15 @@ app.use(express.json());
|
|||||||
app.set('views', path.join(__dirname, 'views'));
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
app.set('view engine', 'ejs');
|
app.set('view engine', 'ejs');
|
||||||
|
|
||||||
|
// Per-host SSO endpoints. nginx routes /__proxy_auth/* on every proxied host to
|
||||||
|
// the app (see ops/nginx_conf/proxy.conf); these run the OIDC flow and set the
|
||||||
|
// per-host session cookie. Mounted before the page router.
|
||||||
|
app.use('/__proxy_auth', require('./routes/host_auth'));
|
||||||
|
|
||||||
// Routes for front end content.
|
// Routes for front end content.
|
||||||
app.use('/', require('./routes/render'));
|
app.use('/', require('./routes/render'));
|
||||||
|
|
||||||
// Routes for API
|
// Routes for API
|
||||||
app.use('/api', require('./routes/api'));
|
app.use('/api', require('./routes/api'));
|
||||||
|
|
||||||
// Catch 404 and forward to error handler. If none of the above routes are
|
// Catch 404 and forward to error handler. If none of the above routes are
|
||||||
|
|||||||
@@ -77,4 +77,13 @@ module.exports = {
|
|||||||
'https://ifconfig.me/ip',
|
'https://ifconfig.me/ip',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Per-host SSO (#57). Reuses conf.oidc for the identity provider. Sessions
|
||||||
|
// are Redis-backed and read directly by OpenResty; the cookie only carries a
|
||||||
|
// random session id.
|
||||||
|
hostSso:{
|
||||||
|
enabled: true,
|
||||||
|
sessionTtl: 28800, // 8 hours, in seconds
|
||||||
|
cookieName: '__proxy_sso',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ require('./user');
|
|||||||
require('./local_group');
|
require('./local_group');
|
||||||
require('./permission');
|
require('./permission');
|
||||||
require('./oidc_state');
|
require('./oidc_state');
|
||||||
|
require('./sso_session');
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const Table = require('.');
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-host SSO models (#57).
|
||||||
|
*
|
||||||
|
* HostSsoState — short-lived, in-flight OIDC authorization request for a
|
||||||
|
* protected host (like OidcState, but carries the target host + post-login
|
||||||
|
* redirect). Auto-expires via TTL.
|
||||||
|
*
|
||||||
|
* SsoSession — an established session after a successful, authorized login.
|
||||||
|
* Keyed by a random session id stored in the browser's `__proxy_sso` cookie.
|
||||||
|
* OpenResty (ops/nginx_conf/hostfeatures.lua) reads `proxy_SsoSession_<sid>`
|
||||||
|
* straight from Redis to gate requests; the allow-list was already enforced at
|
||||||
|
* callback time (utils/host_sso.js), so the Lua side only checks that a session
|
||||||
|
* exists and belongs to this host. Auto-expires via TTL.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SESSION_TTL = (conf.hostSso && conf.hostSso.sessionTtl) || 28800; // 8h
|
||||||
|
|
||||||
|
class HostSsoState extends Table{
|
||||||
|
static _key = 'state';
|
||||||
|
static _ttl = 300; // 5 minutes bounds the auth round-trip / replay
|
||||||
|
static _keyMap = {
|
||||||
|
'created_on': {default: function(){return (new Date).getTime()}},
|
||||||
|
'state': {isRequired: true, type: 'string', min: 8, max: 500},
|
||||||
|
'codeVerifier': {isRequired: true, type: 'string', min: 8, max: 500},
|
||||||
|
'host': {isRequired: true, type: 'string', min: 1, max: 500},
|
||||||
|
'rd': {default: '/', isRequired: false, type: 'string'},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HostSsoState.register();
|
||||||
|
|
||||||
|
class SsoSession extends Table{
|
||||||
|
static _key = 'sid';
|
||||||
|
static _ttl = SESSION_TTL;
|
||||||
|
static _keyMap = {
|
||||||
|
'created_on': {default: function(){return (new Date).getTime()}},
|
||||||
|
'sid': {isRequired: true, type: 'string', min: 16, max: 500},
|
||||||
|
'host': {isRequired: true, type: 'string', min: 1, max: 500},
|
||||||
|
'sub': {isRequired: true, type: 'string', min: 1, max: 500},
|
||||||
|
'email': {default: '', isRequired: false, type: 'string'},
|
||||||
|
'groups': {default: function(){return []}, isRequired: false, type: 'object'},
|
||||||
|
}
|
||||||
|
|
||||||
|
static ttl(){ return SESSION_TTL; }
|
||||||
|
}
|
||||||
|
SsoSession.register();
|
||||||
|
|
||||||
|
module.exports = {HostSsoState, SsoSession};
|
||||||
+3
-3
@@ -11,10 +11,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./bin/www",
|
"start": "node ./bin/www",
|
||||||
"dev": "npx nodemon --ignore public/ ./bin/www",
|
"dev": "npx nodemon --ignore public/ ./bin/www",
|
||||||
"test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js",
|
"test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js",
|
||||||
"test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/unix_socket.test.js",
|
"test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js",
|
||||||
"test:integration": "node --test test/integration/dns_provider.test.js",
|
"test:integration": "node --test test/integration/dns_provider.test.js",
|
||||||
"test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js"
|
"test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-host SSO endpoints (#57), served under /__proxy_auth on EVERY proxied host
|
||||||
|
* (nginx routes that path here; see ops/nginx_conf/proxy.conf). These run the
|
||||||
|
* OIDC authorization-code flow (reusing utils/oidc.js and conf.oidc) and, on a
|
||||||
|
* successful + authorized login, mint a Redis-backed SsoSession and set the
|
||||||
|
* `__proxy_sso` cookie for the host. OpenResty then gates the host on that
|
||||||
|
* session (ops/nginx_conf/hostfeatures.lua).
|
||||||
|
*
|
||||||
|
* Callback style: per-host (option A) — redirect_uri is
|
||||||
|
* https://<host>/__proxy_auth/callback, so each protected host's callback must
|
||||||
|
* be an allowed redirect URI at the IdP (a wildcard redirect URI covers all).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const oidc = require('../utils/oidc');
|
||||||
|
const {Host} = require('../models').models;
|
||||||
|
const {HostSsoState, SsoSession} = require('../models/sso_session');
|
||||||
|
const {identityAllowed} = require('../utils/host_sso');
|
||||||
|
|
||||||
|
const COOKIE = (conf.hostSso && conf.hostSso.cookieName) || '__proxy_sso';
|
||||||
|
|
||||||
|
// Minimal HTML notice page (these endpoints are hit by browsers, not the API).
|
||||||
|
function page(message){
|
||||||
|
return `<!doctype html><html><head><meta charset="utf-8"><title>Sign in</title>`
|
||||||
|
+ `<meta name="viewport" content="width=device-width, initial-scale=1">`
|
||||||
|
+ `<style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;color:#222}</style>`
|
||||||
|
+ `</head><body><p>${String(message).replace(/[<>&]/g, c => ({'<':'<','>':'>','&':'&'}[c]))}</p></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This host's own callback URL — must match between authorize and token steps.
|
||||||
|
function callbackUri(req){
|
||||||
|
return `${req.protocol}://${req.get('host')}/__proxy_auth/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constrain the post-login redirect to this same host (no open redirect). `rd`
|
||||||
|
// may be a bare path or a full same-host URL.
|
||||||
|
function safeRd(req, rd){
|
||||||
|
try{
|
||||||
|
if(!rd) return '/';
|
||||||
|
if(rd.charAt(0) === '/' && rd.charAt(1) !== '/') return rd;
|
||||||
|
let u = new URL(rd);
|
||||||
|
if(u.host === req.get('host')) return u.pathname + u.search;
|
||||||
|
}catch(error){ /* fall through */ }
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCookie(req, name){
|
||||||
|
for(let part of (req.headers.cookie || '').split(';')){
|
||||||
|
let idx = part.indexOf('=');
|
||||||
|
if(idx === -1) continue;
|
||||||
|
if(part.slice(0, idx).trim() === name) return decodeURIComponent(part.slice(idx + 1).trim());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the effective Host record (exact, else via the wildcard lookup tree)
|
||||||
|
// so we can read its SSO allow-lists.
|
||||||
|
async function resolveHost(hostname){
|
||||||
|
try{ return await Host.get(hostname); }catch(error){ /* try wildcard */ }
|
||||||
|
try{ return Host.lookUp(hostname) || null; }catch(error){ return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin login: create PKCE/state, remember the target host + return path, and
|
||||||
|
// redirect the browser to the IdP.
|
||||||
|
router.get('/start', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
if(!conf.oidc || !conf.oidc.enabled){
|
||||||
|
return res.status(503).send(page('SSO is not configured on this proxy.'));
|
||||||
|
}
|
||||||
|
let hostname = req.hostname;
|
||||||
|
let hostRec = await resolveHost(hostname);
|
||||||
|
if(!hostRec || !hostRec.sso_enabled){
|
||||||
|
return res.status(404).send(page('SSO is not enabled for this host.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
let {state, codeVerifier, codeChallenge} = oidc.createAuthRequest();
|
||||||
|
await HostSsoState.create({state, codeVerifier, host: hostname, rd: safeRd(req, req.query.rd)});
|
||||||
|
|
||||||
|
return res.redirect(oidc.buildAuthUrl(state, codeChallenge, callbackUri(req)));
|
||||||
|
}catch(error){
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// OIDC redirect target: validate state, exchange the code, enforce the host's
|
||||||
|
// allow-list, then establish the session and return the user to where they were.
|
||||||
|
router.get('/callback', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
let {code, state} = req.query;
|
||||||
|
if(!code || !state) return res.status(400).send(page('Missing authorization code.'));
|
||||||
|
|
||||||
|
let st = await HostSsoState.get(state).catch(() => null);
|
||||||
|
if(!st) return res.status(400).send(page('Your login session expired. Please try again.'));
|
||||||
|
await st.remove().catch(() => {}); // one-time use
|
||||||
|
|
||||||
|
let hostname = req.hostname;
|
||||||
|
if(st.host !== hostname) return res.status(400).send(page('Login host mismatch.'));
|
||||||
|
|
||||||
|
let tokens = await oidc.exchangeCode(code, st.codeVerifier, callbackUri(req));
|
||||||
|
let claims = await oidc.fetchUserInfo(tokens.access_token);
|
||||||
|
let identity = oidc.claimsToIdentity(claims);
|
||||||
|
let email = claims.email || '';
|
||||||
|
|
||||||
|
let hostRec = await resolveHost(hostname);
|
||||||
|
let allowUsers = (hostRec && hostRec.sso_allow_users) || [];
|
||||||
|
let allowGroups = (hostRec && hostRec.sso_allow_groups) || [];
|
||||||
|
|
||||||
|
if(!identityAllowed({username: identity.username, email, groups: identity.groups}, allowUsers, allowGroups)){
|
||||||
|
return res.status(403).send(page(`You are not authorized to access ${hostname}.`));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sid = oidc.randomToken(32);
|
||||||
|
await SsoSession.create({sid, host: hostname, sub: identity.username, email, groups: identity.groups || []});
|
||||||
|
|
||||||
|
res.cookie(COOKIE, sid, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: req.protocol === 'https',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: SsoSession.ttl() * 1000,
|
||||||
|
});
|
||||||
|
return res.redirect(safeRd(req, st.rd));
|
||||||
|
}catch(error){
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// End the session for this host.
|
||||||
|
router.get('/logout', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
let sid = readCookie(req, COOKIE);
|
||||||
|
if(sid){
|
||||||
|
try{ let s = await SsoSession.get(sid); await s.remove(); }catch(error){ /* gone */ }
|
||||||
|
}
|
||||||
|
res.clearCookie(COOKIE, {path: '/'});
|
||||||
|
return res.redirect(safeRd(req, req.query.rd));
|
||||||
|
}catch(error){
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const {describe, test} = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
const {identityAllowed} = require('../../utils/host_sso');
|
||||||
|
|
||||||
|
describe('identityAllowed (per-host SSO authorization)', () => {
|
||||||
|
const id = {username: 'alice', email: 'alice@x.com', groups: ['dns-team', 'staff']};
|
||||||
|
|
||||||
|
test('empty allow-lists allow any authenticated user', () => {
|
||||||
|
assert.strictEqual(identityAllowed(id, [], []), true);
|
||||||
|
assert.strictEqual(identityAllowed(id, undefined, undefined), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows by username', () => {
|
||||||
|
assert.strictEqual(identityAllowed(id, ['bob', 'alice'], []), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows by email', () => {
|
||||||
|
assert.strictEqual(identityAllowed(id, ['alice@x.com'], []), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows by group membership', () => {
|
||||||
|
assert.strictEqual(identityAllowed(id, [], ['dns-team']), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('denies when neither user nor group matches a non-empty list', () => {
|
||||||
|
assert.strictEqual(identityAllowed(id, ['bob'], ['admins']), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is case-insensitive', () => {
|
||||||
|
assert.strictEqual(identityAllowed(id, ['ALICE'], []), true);
|
||||||
|
assert.strictEqual(identityAllowed(id, [], ['DNS-Team']), true);
|
||||||
|
assert.strictEqual(identityAllowed({username: 'A', email: 'A@X.com'}, ['a@x.com'], []), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles a missing/empty identity gracefully', () => {
|
||||||
|
assert.strictEqual(identityAllowed({}, ['bob'], ['admins']), false);
|
||||||
|
assert.strictEqual(identityAllowed({}, [], []), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure authorization check for per-host SSO (#57).
|
||||||
|
*
|
||||||
|
* After the OIDC dance, the callback decides whether the authenticated identity
|
||||||
|
* may access the host, based on the host's allow-lists. Enforcing here (at
|
||||||
|
* session creation) keeps the OpenResty side simple — the Lua gate only has to
|
||||||
|
* confirm a valid session exists for the host.
|
||||||
|
*
|
||||||
|
* Semantics: empty allow-lists mean "any authenticated user". Otherwise the
|
||||||
|
* identity is allowed if its username OR email is in sso_allow_users, or any of
|
||||||
|
* its groups is in sso_allow_groups. All comparisons are case-insensitive.
|
||||||
|
*/
|
||||||
|
function identityAllowed(identity, allowUsers, allowGroups){
|
||||||
|
identity = identity || {};
|
||||||
|
allowUsers = Array.isArray(allowUsers) ? allowUsers : [];
|
||||||
|
allowGroups = Array.isArray(allowGroups) ? allowGroups : [];
|
||||||
|
|
||||||
|
if(allowUsers.length === 0 && allowGroups.length === 0) return true;
|
||||||
|
|
||||||
|
let lc = s => String(s).trim().toLowerCase();
|
||||||
|
|
||||||
|
let ids = [identity.username, identity.email].filter(Boolean).map(lc);
|
||||||
|
let users = allowUsers.map(lc);
|
||||||
|
if(ids.some(id => users.includes(id))) return true;
|
||||||
|
|
||||||
|
let groups = (Array.isArray(identity.groups) ? identity.groups : []).map(lc);
|
||||||
|
let allow = allowGroups.map(lc);
|
||||||
|
if(groups.some(g => allow.includes(g))) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {identityAllowed};
|
||||||
@@ -36,13 +36,14 @@ function createAuthRequest(){
|
|||||||
return {state, codeVerifier, codeChallenge};
|
return {state, codeVerifier, codeChallenge};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the SSO authorize URL the browser is redirected to.
|
// Build the SSO authorize URL the browser is redirected to. `redirectUri`
|
||||||
function buildAuthUrl(state, codeChallenge){
|
// overrides conf.oidc.redirectUri (per-host SSO uses a per-host callback).
|
||||||
|
function buildAuthUrl(state, codeChallenge, redirectUri){
|
||||||
let o = conf.oidc;
|
let o = conf.oidc;
|
||||||
let params = new URLSearchParams({
|
let params = new URLSearchParams({
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
client_id: o.clientId,
|
client_id: o.clientId,
|
||||||
redirect_uri: o.redirectUri,
|
redirect_uri: redirectUri || o.redirectUri,
|
||||||
scope: (o.scopes || ['openid', 'profile', 'email', 'groups']).join(' '),
|
scope: (o.scopes || ['openid', 'profile', 'email', 'groups']).join(' '),
|
||||||
state,
|
state,
|
||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
@@ -51,13 +52,14 @@ function buildAuthUrl(state, codeChallenge){
|
|||||||
return `${o.authorizationEndpoint}?${params.toString()}`;
|
return `${o.authorizationEndpoint}?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exchange an authorization code for tokens at the token endpoint.
|
// Exchange an authorization code for tokens at the token endpoint. `redirectUri`
|
||||||
async function exchangeCode(code, codeVerifier){
|
// must match the one used in buildAuthUrl (per-host for per-host SSO).
|
||||||
|
async function exchangeCode(code, codeVerifier, redirectUri){
|
||||||
let o = conf.oidc;
|
let o = conf.oidc;
|
||||||
let body = new URLSearchParams({
|
let body = new URLSearchParams({
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code,
|
code,
|
||||||
redirect_uri: o.redirectUri,
|
redirect_uri: redirectUri || o.redirectUri,
|
||||||
client_id: o.clientId,
|
client_id: o.clientId,
|
||||||
client_secret: o.clientSecret,
|
client_secret: o.clientSecret,
|
||||||
code_verifier: codeVerifier,
|
code_verifier: codeVerifier,
|
||||||
|
|||||||
Reference in New Issue
Block a user