Topolo Docs Topolo Platform
Open workspace

Login package

Published0.2.0 OAuth/OIDC, token exchange, ID-token verification, account-link and framework helper contracts with executed local examples.

Login package

Install and integration boundary

Login provides OAuth/OIDC integration helpers for an application that owns its existing local authentication. It builds authorization URLs, exchanges/refreshes tokens, retrieves userinfo, verifies supported JWT signatures and selected claims, shapes account-link metadata, and configures Auth.js/Passport providers. It does not register a client, install a framework, create a local session, store tokens, validate a callback’s state, decide account linking, authorize application actions or establish a resource binding. The client registration and current scopes/redirects remain authoritative platform contracts.

npm install --save-exact @topolo/login@0.2.0
import { createTopoloAuthorizationUrl, exchangeTopoloCode, verifyTopoloIdToken } from '@topolo/login';
// After discovering/registering the actual allowed client, redirects and scopes:
const pending = await createTopoloAuthorizationUrl({ clientId, redirectUri, scopes });
// Persist state, verifier and a generated nonce in the server-side login session.
// The host must send/store its nonce, verify callback state and handle OAuth refusal.
// Consume the one-time code using exactly that session’s verifier and redirect.
const tokenSet = await exchangeTopoloCode({ clientId, clientSecret, redirectUri, code, codeVerifier: pending.codeVerifier });
// Validate required claim types/presence and host policy, then verify signature/claims.
const verified = await verifyTopoloIdToken({ idToken: tokenSet.id_token!, issuer, audience: clientId, nonce: storedNonce });

This is integration pseudocode around the actual exported helpers; it is not a runnable live sign-in without a registered client/session. State and nonce must be checked before establishing a local session. Keep client secrets/refresh tokens in protected server storage; never log them or put them in URLs/browser sessions. The package has no state store, token refresh scheduler or callback/session router. Use the issued access token with the SDK after successful login, then discover credential-scoped actions/resources through CLI/MCP.

Authorization URL and PKCE

createTopoloAuthorizationUrl requires non-null/nonempty clientId and redirectUri, but does not validate their registered eligibility, URL syntax, whitespace, scope eligibility or type beyond those presence checks. Default auth origin is https://auth.topolo.app. Defaults are scope openid/profile/email, random24-byte base64url state, a64-character PKCE verifier and SHA-256/S256 challenge. Caller-supplied state/verifier are not validated for length/entropy; plain mode is supported explicitly. Do not weaken PKCE through plain or caller-provided constants in a live integration.

Scopes are trimmed, deduplicated and empty strings removed. The authorize route is resolved with new URL(/api/developer-oauth/authorize,base): an origin path prefix is discarded. Trailing slashes are removed. loginHint/prompt are included only when truthy. extraParams converts number/boolean values to strings and skips null/undefined, but can overwrite reserved parameters after the defaults; the returned state/scopes/challenge remain the original values. Restrict extraParams to reviewed nonreserved keys and compare the actual URL before redirecting. Nonce is not a first-class option; the host may supply it through reviewed extraParams and must store/pass it for verification.

Default production origin and default scope constants

({origin:api.DEFAULT_TOPOLO_AUTH_ORIGIN,scopes:api.DEFAULT_TOPOLO_SCOPES})

Captured local result:

{
  "result": {
    "origin": "https://auth.topolo.app",
    "scopes": [
      "openid",
      "profile",
      "email"
    ]
  }
}

S256 URL with fixed fixture state and verifier

api.createTopoloAuthorizationUrl({authBaseUrl:fixtureIssuer,clientId:fixtureAudience,redirectUri:fixtureIssuer+'/callback',state:'fixture-state',codeVerifier:'fixture-verifier'})

Captured local result:

{
  "result": {
    "url": "https://fixture.invalid/api/developer-oauth/authorize?response_type=code&client_id=fixture_client&redirect_uri=https%3A%2F%2Ffixture.invalid%2Fcallback&scope=openid+profile+email&state=fixture-state&code_challenge=8Dw_rXYDuMBDOofOjlxvMNEALsULmI6c47hS_o9Dkqg&code_challenge_method=S256",
    "state": "fixture-state",
    "codeVerifier": "fixture-verifier",
    "codeChallenge": "8Dw_rXYDuMBDOofOjlxvMNEALsULmI6c47hS_o9Dkqg",
    "scopes": [
      "openid",
      "profile",
      "email"
    ]
  }
}

Generated state and verifier lengths without recording random values

api.createTopoloAuthorizationUrl({authBaseUrl:fixtureIssuer,clientId:fixtureAudience,redirectUri:fixtureIssuer+'/callback'}).then(r=>({stateLength:r.state.length,verifierLength:r.codeVerifier.length,challengeLength:r.codeChallenge.length,scopes:r.scopes}))

Captured local result:

{
  "result": {
    "stateLength": 32,
    "verifierLength": 64,
    "challengeLength": 43,
    "scopes": [
      "openid",
      "profile",
      "email"
    ]
  }
}

Scope normalization and optional authorize parameters

api.createTopoloAuthorizationUrl({authBaseUrl:fixtureIssuer+'///',clientId:fixtureAudience,redirectUri:fixtureIssuer+'/callback',state:'fixture-state',codeVerifier:'fixture-verifier',scopes:['openid',' profile ','openid','','email'],loginHint:'fixture@example.invalid',prompt:'consent',extraParams:{nonce:'fixture-nonce',fixture_flag:true,fixture_count:2,ignored:null}})

Captured local result:

{
  "result": {
    "url": "https://fixture.invalid/api/developer-oauth/authorize?response_type=code&client_id=fixture_client&redirect_uri=https%3A%2F%2Ffixture.invalid%2Fcallback&scope=openid+profile+email&state=fixture-state&code_challenge=8Dw_rXYDuMBDOofOjlxvMNEALsULmI6c47hS_o9Dkqg&code_challenge_method=S256&login_hint=fixture%40example.invalid&prompt=consent&nonce=fixture-nonce&fixture_flag=true&fixture_count=2",
    "state": "fixture-state",
    "codeVerifier": "fixture-verifier",
    "codeChallenge": "8Dw_rXYDuMBDOofOjlxvMNEALsULmI6c47hS_o9Dkqg",
    "scopes": [
      "openid",
      "profile",
      "email"
    ]
  }
}

Reserved extra parameter overwrite remains visible in final URL

api.createTopoloAuthorizationUrl({authBaseUrl:fixtureIssuer,clientId:fixtureAudience,redirectUri:fixtureIssuer+'/callback',state:'stored-fixture-state',codeVerifier:'fixture-verifier',extraParams:{state:'overwritten-fixture-state'}}).then(r=>({returnedState:r.state,urlState:new URL(r.url).searchParams.get('state')}))

Captured local result:

{
  "result": {
    "returnedState": "stored-fixture-state",
    "urlState": "overwritten-fixture-state"
  }
}

Missing client ID is refused

api.createTopoloAuthorizationUrl({clientId:'',redirectUri:fixtureIssuer+'/callback'})

Captured local result:

{
  "error": {
    "name": "TopoloLoginError",
    "message": "clientId is required"
  }
}

Missing redirect is refused

api.createTopoloAuthorizationUrl({clientId:fixtureAudience,redirectUri:''})

Captured local result:

{
  "error": {
    "name": "TopoloLoginError",
    "message": "redirectUri is required"
  }
}

Explicit plain mode uses the fixture verifier unchanged

api.createTopoloAuthorizationUrl({authBaseUrl:fixtureIssuer,clientId:fixtureAudience,redirectUri:fixtureIssuer+'/callback',state:'fixture-state',codeVerifier:'fixture-verifier',codeChallengeMethod:'plain'})

Captured local result:

{
  "result": {
    "url": "https://fixture.invalid/api/developer-oauth/authorize?response_type=code&client_id=fixture_client&redirect_uri=https%3A%2F%2Ffixture.invalid%2Fcallback&scope=openid+profile+email&state=fixture-state&code_challenge=fixture-verifier&code_challenge_method=plain",
    "state": "fixture-state",
    "codeVerifier": "fixture-verifier",
    "codeChallenge": "fixture-verifier",
    "scopes": [
      "openid",
      "profile",
      "email"
    ]
  }
}

Token exchange, refresh and userinfo

exchangeTopoloCode requires clientId/code/redirectUri. It POSTs an application/x-www-form-urlencoded body with authorization_code grant; optional clientSecret/verifier are included unless undefined or empty. refreshTopoloToken requires clientId/refreshToken and posts refresh_token grant to the same token route. Both use Accept application/json. fetchTopoloUserInfo requires accessToken and sends Bearer authorization to the userinfo route. An injected fetch is used when supplied; otherwise global fetch is required. There is no retry, timeout, cancellation option, refresh lock, OAuth client authentication policy enforcement or response schema validation.

The helper reads the whole response text. Empty text becomes{}, valid JSON is returned unchanged, non-JSON becomes{raw:text}. Any HTTP2xx is returned as the declared type even if fields are missing or the body is an OAuth refusal. Non2xx becomes TopoloLoginError with status/payload and string oauthError; error_description is preferred as message, then string error, then fallback. Transport exceptions propagate unchanged. Validate the actual response shape and never treat a2xx malformed body as a usable token. After uncertain one-time code exchange, avoid parallel replays; preserve the request outcome and restart the approved login journey if the code is already consumed. A failed refresh should end/re-authenticate the local session according to the server’s published refusal, not guess credentials.

The following harness never contacts Auth; fixture tokens confer no access:

async function transportFixture(kind,{status=200,body,raw,options={}}={}){
 const calls=[],fetch=async(input,init)=>{const url=String(input);assert.ok(url.startsWith(fixtureIssuer+'/'),'No live transport allowed');const params=init?.body instanceof URLSearchParams?Object.fromEntries(init.body):null;calls.push({url,method:init?.method??'GET',headers:{accept:new Headers(init?.headers).get('Accept'),contentType:new Headers(init?.headers).get('Content-Type'),authorizationPresent:new Headers(init?.headers).has('Authorization')},form:params});return raw!==undefined?new Response(raw,{status}):Response.json(body??(kind==='userinfo'?{sub:'usr_fixture',email:'fixture@example.invalid',email_verified:true,org_id:'org_fixture'}:{access_token:'fixture-access-no-live-access',token_type:'Bearer',expires_in:300,refresh_token:'fixture-refresh-no-live-access'}),{status});};
 const common={authBaseUrl:fixtureIssuer,fetch,...options};try{let result;if(kind==='exchange')result=await api.exchangeTopoloCode({clientId:fixtureAudience,code:'fixture-code',redirectUri:fixtureIssuer+'/callback',codeVerifier:'fixture-verifier',...common});else if(kind==='refresh')result=await api.refreshTopoloToken({clientId:fixtureAudience,refreshToken:'fixture-refresh-no-live-access',...common});else result=await api.fetchTopoloUserInfo({accessToken:'fixture-access-no-live-access',...common});return {calls,result};}catch(e){return {calls,error:{name:e.name,message:e.message,status:e.status,oauthError:e.oauthError,payload:e.payload}};}
}

Successful code exchange captures exact form and response

transportFixture('exchange')

Captured local result:

{
  "result": {
    "calls": [
      {
        "url": "https://fixture.invalid/api/developer-oauth/token",
        "method": "POST",
        "headers": {
          "accept": "application/json",
          "contentType": "application/x-www-form-urlencoded",
          "authorizationPresent": false
        },
        "form": {
          "grant_type": "authorization_code",
          "client_id": "fixture_client",
          "code": "fixture-code",
          "redirect_uri": "https://fixture.invalid/callback",
          "code_verifier": "fixture-verifier"
        }
      }
    ],
    "result": {
      "access_token": "fixture-access-no-live-access",
      "token_type": "Bearer",
      "expires_in": 300,
      "refresh_token": "fixture-refresh-no-live-access"
    }
  }
}

Confidential-client fixture adds its server-only secret field

transportFixture('exchange',{options:{clientSecret:'fixture-secret-no-live-access'}})

Captured local result:

{
  "result": {
    "calls": [
      {
        "url": "https://fixture.invalid/api/developer-oauth/token",
        "method": "POST",
        "headers": {
          "accept": "application/json",
          "contentType": "application/x-www-form-urlencoded",
          "authorizationPresent": false
        },
        "form": {
          "grant_type": "authorization_code",
          "client_id": "fixture_client",
          "client_secret": "fixture-secret-no-live-access",
          "code": "fixture-code",
          "redirect_uri": "https://fixture.invalid/callback",
          "code_verifier": "fixture-verifier"
        }
      }
    ],
    "result": {
      "access_token": "fixture-access-no-live-access",
      "token_type": "Bearer",
      "expires_in": 300,
      "refresh_token": "fixture-refresh-no-live-access"
    }
  }
}

Successful refresh captures exact form and response

transportFixture('refresh')

Captured local result:

{
  "result": {
    "calls": [
      {
        "url": "https://fixture.invalid/api/developer-oauth/token",
        "method": "POST",
        "headers": {
          "accept": "application/json",
          "contentType": "application/x-www-form-urlencoded",
          "authorizationPresent": false
        },
        "form": {
          "grant_type": "refresh_token",
          "client_id": "fixture_client",
          "refresh_token": "fixture-refresh-no-live-access"
        }
      }
    ],
    "result": {
      "access_token": "fixture-access-no-live-access",
      "token_type": "Bearer",
      "expires_in": 300,
      "refresh_token": "fixture-refresh-no-live-access"
    }
  }
}

Successful userinfo captures authorization presence and claims

transportFixture('userinfo')

Captured local result:

{
  "result": {
    "calls": [
      {
        "url": "https://fixture.invalid/api/developer-oauth/userinfo",
        "method": "GET",
        "headers": {
          "accept": "application/json",
          "contentType": null,
          "authorizationPresent": true
        },
        "form": null
      }
    ],
    "result": {
      "sub": "usr_fixture",
      "email": "fixture@example.invalid",
      "email_verified": true,
      "org_id": "org_fixture"
    }
  }
}

Invalid grant exposes status and OAuth refusal code

transportFixture('exchange',{status:400,body:{error:'invalid_grant',error_description:'Fixture authorization code is consumed'}})

Captured local result:

{
  "result": {
    "calls": [
      {
        "url": "https://fixture.invalid/api/developer-oauth/token",
        "method": "POST",
        "headers": {
          "accept": "application/json",
          "contentType": "application/x-www-form-urlencoded",
          "authorizationPresent": false
        },
        "form": {
          "grant_type": "authorization_code",
          "client_id": "fixture_client",
          "code": "fixture-code",
          "redirect_uri": "https://fixture.invalid/callback",
          "code_verifier": "fixture-verifier"
        }
      }
    ],
    "error": {
      "name": "TopoloLoginError",
      "message": "Fixture authorization code is consumed",
      "status": 400,
      "oauthError": "invalid_grant",
      "payload": {
        "error": "invalid_grant",
        "error_description": "Fixture authorization code is consumed"
      }
    }
  }
}

Denied userinfo propagates status and payload

transportFixture('userinfo',{status:401,body:{error:'invalid_token'}})

Captured local result:

{
  "result": {
    "calls": [
      {
        "url": "https://fixture.invalid/api/developer-oauth/userinfo",
        "method": "GET",
        "headers": {
          "accept": "application/json",
          "contentType": null,
          "authorizationPresent": true
        },
        "form": null
      }
    ],
    "error": {
      "name": "TopoloLoginError",
      "message": "invalid_token",
      "status": 401,
      "oauthError": "invalid_token",
      "payload": {
        "error": "invalid_token"
      }
    }
  }
}

Non-JSON gateway response uses fallback error and raw payload

transportFixture('refresh',{status:503,raw:'Fixture upstream unavailable'})

Captured local result:

{
  "result": {
    "calls": [
      {
        "url": "https://fixture.invalid/api/developer-oauth/token",
        "method": "POST",
        "headers": {
          "accept": "application/json",
          "contentType": "application/x-www-form-urlencoded",
          "authorizationPresent": false
        },
        "form": {
          "grant_type": "refresh_token",
          "client_id": "fixture_client",
          "refresh_token": "fixture-refresh-no-live-access"
        }
      }
    ],
    "error": {
      "name": "TopoloLoginError",
      "message": "Topolo token request failed",
      "status": 503,
      "payload": {
        "raw": "Fixture upstream unavailable"
      }
    }
  }
}

Empty successful body returns an unvalidated empty object

transportFixture('exchange',{raw:''})

Captured local result:

{
  "result": {
    "calls": [
      {
        "url": "https://fixture.invalid/api/developer-oauth/token",
        "method": "POST",
        "headers": {
          "accept": "application/json",
          "contentType": "application/x-www-form-urlencoded",
          "authorizationPresent": false
        },
        "form": {
          "grant_type": "authorization_code",
          "client_id": "fixture_client",
          "code": "fixture-code",
          "redirect_uri": "https://fixture.invalid/callback",
          "code_verifier": "fixture-verifier"
        }
      }
    ],
    "result": {}
  }
}

Successful HTTP status does not validate token fields

transportFixture('exchange',{body:{unexpected:true}})

Captured local result:

{
  "result": {
    "calls": [
      {
        "url": "https://fixture.invalid/api/developer-oauth/token",
        "method": "POST",
        "headers": {
          "accept": "application/json",
          "contentType": "application/x-www-form-urlencoded",
          "authorizationPresent": false
        },
        "form": {
          "grant_type": "authorization_code",
          "client_id": "fixture_client",
          "code": "fixture-code",
          "redirect_uri": "https://fixture.invalid/callback",
          "code_verifier": "fixture-verifier"
        }
      }
    ],
    "result": {
      "unexpected": true
    }
  }
}

Missing code is refused before fetch

api.exchangeTopoloCode({clientId:fixtureAudience,redirectUri:fixtureIssuer+'/callback',code:''})

Captured local result:

{
  "error": {
    "name": "TopoloLoginError",
    "message": "code is required"
  }
}

Missing refresh token is refused

api.refreshTopoloToken({clientId:fixtureAudience,refreshToken:''})

Captured local result:

{
  "error": {
    "name": "TopoloLoginError",
    "message": "refreshToken is required"
  }
}

Missing userinfo access token is refused

api.fetchTopoloUserInfo({accessToken:''})

Captured local result:

{
  "error": {
    "name": "TopoloLoginError",
    "message": "accessToken is required"
  }
}

Transport errors retain their original error type

api.refreshTopoloToken({clientId:fixtureAudience,refreshToken:'fixture-no-access',authBaseUrl:fixtureIssuer,fetch:async()=>{throw new TypeError('Fixture transport failure');}})

Captured local result:

{
  "error": {
    "name": "TypeError",
    "message": "Fixture transport failure"
  }
}

ID-token verification and host claim checks

verifyTopoloIdToken decodes the first three nonempty compact-JWT segments and accepts RS256/ES256. It imports a matching public JWK through WebCrypto and verifies the signature before checking claims. Only issuer exact equality, audience overlap, expiration comparison, numeric nbf and an optional caller-provided truthy nonce are checked. Default issuer is the production Auth origin; default clock tolerance60seconds and now epoch seconds. Expiration is rejected when exp≤now−tolerance; numeric nbf is rejected when later than now+tolerance. Audience arrays match any expected audience, not all. Nonce is checked only if the host supplies it.

The helper does not validate the full required claim shape/types: missing exp or sub can pass for a locally signed token; iat, azp, auth_time, maximum token age and trailing JWT segments are not comprehensively checked. The TypeScript interface is not runtime validation. The host must require appropriate types/presence and policy before accepting a token or linking an account. Do not describe this helper as full OIDC certification.

JWKS is supplied directly or fetched from explicit jwksUrl/default issuer+/api/developer-oauth/jwks. There is no discovery-based issuer validation, trusted-origin allowlist, JWKS cache/rotation retry or transport timeout in this helper. Key selection first matches kid, then alg when no kid, then a single-key fallback; a sole key may be selected despite a mismatched kid. Public keys/issuer must come from the trusted registered integration, not token-controlled URLs. Unsupported algorithm/missing key/bad signature/claim mismatch raise TopoloLoginError; malformed JSON/base64 or WebCrypto import failures can retain native error types.

Local RSA2048 and P-256 key pairs are generated only for the captures below. No private key is exported or persisted and no token is usable against Topolo. Each token is signed locally with known fixture claims; successful verification proves local cryptographic behavior, not a real Auth-issued login:

async function jwtFixture({alg='RS256',claims={},options={},mode='normal'}={}){
 const header={alg,kid:alg==='RS256'?'fixture-rsa':'fixture-ec'},body={iss:fixtureIssuer,sub:'usr_fixture',aud:fixtureAudience,exp:fixtureNow+300,iat:fixtureNow-10,nonce:'fixture-nonce',...claims};
 if(mode==='missing-exp')delete body.exp;if(mode==='missing-sub')delete body.sub;
 const unsigned=encode(header)+'.'+encode(body),pair=alg==='RS256'?rsa:ec,algorithm=alg==='RS256'?{name:'RSASSA-PKCS1-v1_5'}:{name:'ECDSA',hash:'SHA-256'},signature=new Uint8Array(await crypto.subtle.sign(algorithm,pair.privateKey,new TextEncoder().encode(unsigned)));
 if(mode==='bad-signature')signature[0]^=1;
 const token=unsigned+'.'+Buffer.from(signature).toString('base64url'),request={idToken:token,issuer:fixtureIssuer,audience:fixtureAudience,nonce:'fixture-nonce',now:fixtureNow,jwks:{keys:[publicKeys[alg]]},...options};
 const result=await api.verifyTopoloIdToken(request);return {algorithm:alg,...result};
}

Locally signed RS256 verifies

jwtFixture()

Captured local result:

{
  "result": {
    "algorithm": "RS256",
    "header": {
      "alg": "RS256",
      "kid": "fixture-rsa"
    },
    "claims": {
      "iss": "https://fixture.invalid",
      "sub": "usr_fixture",
      "aud": "fixture_client",
      "exp": 1791374700,
      "iat": 1791374390,
      "nonce": "fixture-nonce"
    }
  }
}

Locally signed ES256 verifies

jwtFixture({alg:'ES256'})

Captured local result:

{
  "result": {
    "algorithm": "ES256",
    "header": {
      "alg": "ES256",
      "kid": "fixture-ec"
    },
    "claims": {
      "iss": "https://fixture.invalid",
      "sub": "usr_fixture",
      "aud": "fixture_client",
      "exp": 1791374700,
      "iat": 1791374390,
      "nonce": "fixture-nonce"
    }
  }
}

Invalid signature is refused

jwtFixture({mode:'bad-signature'})

Captured local result:

{
  "error": {
    "name": "TopoloLoginError",
    "message": "id_token signature verification failed"
  }
}

Issuer mismatch is refused

jwtFixture({claims:{iss:'https://different.fixture.invalid'}})

Captured local result:

{
  "error": {
    "name": "TopoloLoginError",
    "message": "id_token issuer mismatch"
  }
}

Audience mismatch is refused

jwtFixture({claims:{aud:'different_fixture'}})

Captured local result:

{
  "error": {
    "name": "TopoloLoginError",
    "message": "id_token audience mismatch"
  }
}

Audience arrays require any overlap

jwtFixture({claims:{aud:['other_fixture',fixtureAudience]},options:{audience:['third_fixture',fixtureAudience]}})

Captured local result:

{
  "result": {
    "algorithm": "RS256",
    "header": {
      "alg": "RS256",
      "kid": "fixture-rsa"
    },
    "claims": {
      "iss": "https://fixture.invalid",
      "sub": "usr_fixture",
      "aud": [
        "other_fixture",
        "fixture_client"
      ],
      "exp": 1791374700,
      "iat": 1791374390,
      "nonce": "fixture-nonce"
    }
  }
}

Expiration outside the default tolerance is refused

jwtFixture({claims:{exp:fixtureNow-61}})

Captured local result:

{
  "error": {
    "name": "TopoloLoginError",
    "message": "id_token expired"
  }
}

Small expiration skew is accepted by default

jwtFixture({claims:{exp:fixtureNow-59}})

Captured local result:

{
  "result": {
    "algorithm": "RS256",
    "header": {
      "alg": "RS256",
      "kid": "fixture-rsa"
    },
    "claims": {
      "iss": "https://fixture.invalid",
      "sub": "usr_fixture",
      "aud": "fixture_client",
      "exp": 1791374341,
      "iat": 1791374390,
      "nonce": "fixture-nonce"
    }
  }
}

Zero tolerance rejects an expired token

jwtFixture({claims:{exp:fixtureNow-1},options:{clockToleranceSeconds:0}})

Captured local result:

{
  "error": {
    "name": "TopoloLoginError",
    "message": "id_token expired"
  }
}

Future not-before outside tolerance is refused

jwtFixture({claims:{nbf:fixtureNow+61}})

Captured local result:

{
  "error": {
    "name": "TopoloLoginError",
    "message": "id_token is not yet valid"
  }
}

Nonce mismatch is refused when expected nonce is supplied

jwtFixture({claims:{nonce:'wrong-fixture-nonce'}})

Captured local result:

{
  "error": {
    "name": "TopoloLoginError",
    "message": "id_token nonce mismatch"
  }
}

No expected nonce skips nonce comparison

jwtFixture({claims:{nonce:'other-fixture-nonce'},options:{nonce:undefined}})

Captured local result:

{
  "result": {
    "algorithm": "RS256",
    "header": {
      "alg": "RS256",
      "kid": "fixture-rsa"
    },
    "claims": {
      "iss": "https://fixture.invalid",
      "sub": "usr_fixture",
      "aud": "fixture_client",
      "exp": 1791374700,
      "iat": 1791374390,
      "nonce": "other-fixture-nonce"
    }
  }
}

Missing expiration demonstrates the host required-claim responsibility

jwtFixture({mode:'missing-exp'})

Captured local result:

{
  "result": {
    "algorithm": "RS256",
    "header": {
      "alg": "RS256",
      "kid": "fixture-rsa"
    },
    "claims": {
      "iss": "https://fixture.invalid",
      "sub": "usr_fixture",
      "aud": "fixture_client",
      "iat": 1791374390,
      "nonce": "fixture-nonce"
    }
  }
}

Missing subject demonstrates the host required-claim responsibility

jwtFixture({mode:'missing-sub'})

Captured local result:

{
  "result": {
    "algorithm": "RS256",
    "header": {
      "alg": "RS256",
      "kid": "fixture-rsa"
    },
    "claims": {
      "iss": "https://fixture.invalid",
      "aud": "fixture_client",
      "exp": 1791374700,
      "iat": 1791374390,
      "nonce": "fixture-nonce"
    }
  }
}

Future iat is not independently rejected

jwtFixture({claims:{iat:fixtureNow+1000}})

Captured local result:

{
  "result": {
    "algorithm": "RS256",
    "header": {
      "alg": "RS256",
      "kid": "fixture-rsa"
    },
    "claims": {
      "iss": "https://fixture.invalid",
      "sub": "usr_fixture",
      "aud": "fixture_client",
      "exp": 1791374700,
      "iat": 1791375400,
      "nonce": "fixture-nonce"
    }
  }
}

Empty JWKS is refused

jwtFixture({options:{jwks:{keys:[]}}})

Captured local result:

{
  "error": {
    "name": "TopoloLoginError",
    "message": "No Topolo JWKS key found for kid fixture-rsa"
  }
}

Single-key fallback can select a mismatched kid

jwtFixture({options:{jwks:{keys:[{...publicKeys.RS256,kid:'different-fixture-kid'}]}}})

Captured local result:

{
  "result": {
    "algorithm": "RS256",
    "header": {
      "alg": "RS256",
      "kid": "fixture-rsa"
    },
    "claims": {
      "iss": "https://fixture.invalid",
      "sub": "usr_fixture",
      "aud": "fixture_client",
      "exp": 1791374700,
      "iat": 1791374390,
      "nonce": "fixture-nonce"
    }
  }
}

Malformed compact token is refused

api.verifyTopoloIdToken({idToken:'fixture.notcompact',issuer:fixtureIssuer,audience:fixtureAudience,jwks:{keys:[]}})

Captured local result:

{
  "error": {
    "name": "TopoloLoginError",
    "message": "id_token must be a compact JWT"
  }
}

Unsupported algorithm is refused before key import

api.verifyTopoloIdToken({idToken:Buffer.from(JSON.stringify({alg:'HS256'})).toString('base64url')+'.'+Buffer.from('{}').toString('base64url')+'.Zml4dHVyZQ',issuer:fixtureIssuer,audience:fixtureAudience,jwks:{keys:[]}})

Captured local result:

{
  "error": {
    "name": "TopoloLoginError",
    "message": "Unsupported id_token alg: HS256"
  }
}

Account linking contract

createAccountLinkPayload returns metadata only; it does not verify userInfo, persist a link, create a user, confirm ownership or prevent duplicate/unsafe linking. localUserId is presence-checked; topolo_user_id is String(sub||empty), so missing sub produces an empty ID. Host validation must reject absent/malformed identity. org_id uses a legacy organization_id fallback and only nonempty strings; explicit topoloContextId/topoloAppId use nullish fallback to userInfo values. A caller null therefore does not force clearing a populated userInfo context.

connectedAt defaults to current ISO time; Date values use toISOString, strings are returned without date validation. lastSeenAt defaults to connectedAt. Never auto-merge accounts solely by matching email. Link an authenticated local user through an explicit approved flow and bind identity using the trusted issuer/subject, preserving the correct organization/context. Local metadata is not an application permission/resource grant.

api.createAccountLinkPayload({localUserId:'local_fixture',userInfo:{sub:'usr_fixture',org_id:'org_fixture',topolo_context_id:'personal'},topoloContextId:'org:org_fixture',topoloAppId:'app_fixture',connectedAt:new Date('2026-10-07T00:00:00Z')})

Captured local result:

{
  "result": {
    "local_user_id": "local_fixture",
    "topolo_user_id": "usr_fixture",
    "topolo_org_id": "org_fixture",
    "topolo_context_id": "org:org_fixture",
    "topolo_app_id": "app_fixture",
    "connected_at": "2026-10-07T00:00:00.000Z",
    "last_seen_at": "2026-10-07T00:00:00.000Z"
  }
}

Legacy organization ID and userinfo context fallback

api.createAccountLinkPayload({localUserId:'local_fixture',userInfo:{sub:'usr_fixture',organization_id:'org_legacy_fixture',topolo_context_id:'context_fixture'},topoloContextId:null,connectedAt:'2026-10-07T00:00:00Z'})

Captured local result:

{
  "result": {
    "local_user_id": "local_fixture",
    "topolo_user_id": "usr_fixture",
    "topolo_org_id": "org_legacy_fixture",
    "topolo_context_id": "context_fixture",
    "topolo_app_id": null,
    "connected_at": "2026-10-07T00:00:00Z",
    "last_seen_at": "2026-10-07T00:00:00Z"
  }
}

Missing subject and unvalidated date string remain visible

api.createAccountLinkPayload({localUserId:'local_fixture',userInfo:{},connectedAt:'fixture-invalid-date'})

Captured local result:

{
  "result": {
    "local_user_id": "local_fixture",
    "topolo_user_id": "",
    "topolo_org_id": null,
    "topolo_context_id": null,
    "topolo_app_id": null,
    "connected_at": "fixture-invalid-date",
    "last_seen_at": "fixture-invalid-date"
  }
}

Missing local user ID is refused

api.createAccountLinkPayload({localUserId:'',userInfo:{sub:'usr_fixture'}})

Captured local result:

{
  "error": {
    "name": "TopoloLoginError",
    "message": "localUserId is required"
  }
}

Auth.js and Passport provider helpers

createTopoloAuthJsProvider returns an OIDC provider object with issuer/wellKnown, client fields, checks pkce/state and normalized authorization scope. Defaults id topolo/name Topolo, overridable. Its profile mapper returns local id/name/email/image plus Topolo identity/org/context/app metadata, preserving nulls for missing display fields. The host framework still owns actual discovery, callback/session checks, token persistence, refresh and local account linking. Do not expose the entire provider object when it contains a real clientSecret.

createTopoloPassportStrategyConfig requires clientId/callbackUrl and returns authorize/token/userinfo URLs, clientID/clientSecret/callbackURL, normalized scope and state:true/pkce:true. It only configures the strategy; it does not fetch a profile or mount Express/Passport. These helpers do not verify installed framework version compatibility. The package README references example directories not shipped in the npm artifact; use the actual contracts below instead of assuming those files are installed.

Auth.js provider configuration without a client secret

(()=>{const p=api.createTopoloAuthJsProvider({authBaseUrl:fixtureIssuer,clientId:fixtureAudience,scopes:['openid','profile','email']});return {...p,profile:'function(profile)'};})()

Captured local result:

{
  "result": {
    "id": "topolo",
    "name": "Topolo",
    "type": "oidc",
    "issuer": "https://fixture.invalid",
    "wellKnown": "https://fixture.invalid/.well-known/openid-configuration",
    "clientId": "fixture_client",
    "checks": [
      "pkce",
      "state"
    ],
    "authorization": {
      "params": {
        "scope": "openid profile email"
      }
    },
    "profile": "function(profile)"
  }
}

Auth.js profile mapper preserves verified profile metadata

api.createTopoloAuthJsProvider({authBaseUrl:fixtureIssuer,clientId:fixtureAudience}).profile({sub:'usr_fixture',email:'fixture@example.invalid',org_id:'org_fixture',topolo_context_id:'context_fixture',topolo_app_id:'app_fixture'})

Captured local result:

{
  "result": {
    "id": "usr_fixture",
    "name": null,
    "email": "fixture@example.invalid",
    "image": null,
    "topoloUserId": "usr_fixture",
    "topoloOrgId": "org_fixture",
    "topoloContextId": "context_fixture",
    "topoloAppId": "app_fixture"
  }
}

Passport configuration publishes PKCE/state and endpoint roles

api.createTopoloPassportStrategyConfig({authBaseUrl:fixtureIssuer,clientId:fixtureAudience,callbackUrl:fixtureIssuer+'/callback'})

Captured local result:

{
  "result": {
    "authorizationURL": "https://fixture.invalid/api/developer-oauth/authorize",
    "tokenURL": "https://fixture.invalid/api/developer-oauth/token",
    "userInfoURL": "https://fixture.invalid/api/developer-oauth/userinfo",
    "clientID": "fixture_client",
    "callbackURL": "https://fixture.invalid/callback",
    "scope": [
      "openid",
      "profile",
      "email"
    ],
    "state": true,
    "pkce": true
  }
}

Passport missing callback URL is refused

api.createTopoloPassportStrategyConfig({clientId:fixtureAudience,callbackUrl:''})

Captured local result:

{
  "error": {
    "name": "TopoloLoginError",
    "message": "callbackUrl is required"
  }
}

TopoloLoginError retains explicit refusal metadata

(()=>{const e=new api.TopoloLoginError('Fixture refused',{status:400,oauthError:'invalid_grant',payload:{error:'invalid_grant'}});return {name:e.name,message:e.message,status:e.status,oauthError:e.oauthError,payload:e.payload};})()

Captured local result:

{
  "result": {
    "name": "TopoloLoginError",
    "message": "Fixture refused",
    "status": 400,
    "oauthError": "invalid_grant",
    "payload": {
      "error": "invalid_grant"
    }
  }
}

Complete input and output type contracts

declare const DEFAULT_TOPOLO_AUTH_ORIGIN = "https://auth.topolo.app";
declare const DEFAULT_TOPOLO_SCOPES: readonly ["openid", "profile", "email"];
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
interface TopoloAuthorizationUrlOptions {
    authBaseUrl?: string;
    clientId: string;
    redirectUri: string;
    scopes?: readonly string[];
    state?: string;
    codeVerifier?: string;
    codeChallengeMethod?: 'S256' | 'plain';
    loginHint?: string;
    prompt?: string;
    extraParams?: Record<string, string | number | boolean | undefined | null>;
}
interface TopoloAuthorizationUrlResult {
    url: string;
    state: string;
    codeVerifier: string;
    codeChallenge: string;
    scopes: string[];
}
interface TopoloTokenRequestOptions {
    authBaseUrl?: string;
    clientId: string;
    clientSecret?: string;
    fetch?: FetchLike;
}
interface ExchangeTopoloCodeOptions extends TopoloTokenRequestOptions {
    code: string;
    redirectUri: string;
    codeVerifier?: string;
}
interface RefreshTopoloTokenOptions extends TopoloTokenRequestOptions {
    refreshToken: string;
}
interface TopoloTokenResponse {
    access_token: string;
    token_type: 'Bearer' | string;
    expires_in: number;
    refresh_token?: string;
    scope?: string;
    id_token?: string;
    [key: string]: unknown;
}
interface TopoloUserInfo {
    sub: string;
    email?: string;
    email_verified?: boolean;
    name?: string;
    picture?: string;
    org_id?: string | null;
    org_slug?: string | null;
    topolo_context_id?: string | null;
    topolo_app_id?: string | null;
    scopes?: string[];
    [key: string]: unknown;
}
interface FetchTopoloUserInfoOptions {
    authBaseUrl?: string;
    accessToken: string;
    fetch?: FetchLike;
}
interface JsonWebKeySet {
    keys: TopoloJsonWebKey[];
}
type TopoloJsonWebKey = JsonWebKey & {
    kid?: string;
    alg?: string;
    use?: string;
};
interface VerifyTopoloIdTokenOptions {
    idToken: string;
    issuer?: string;
    audience: string | readonly string[];
    nonce?: string;
    jwks?: JsonWebKeySet;
    jwksUrl?: string;
    fetch?: FetchLike;
    clockToleranceSeconds?: number;
    now?: number;
}
interface VerifiedTopoloIdToken {
    header: Record<string, unknown>;
    claims: TopoloIdTokenClaims;
}
interface TopoloIdTokenClaims {
    iss: string;
    sub: string;
    aud: string | string[];
    exp: number;
    iat: number;
    nonce?: string;
    email?: string;
    email_verified?: boolean;
    name?: string;
    org_id?: string | null;
    org_slug?: string | null;
    topolo_context_id?: string | null;
    topolo_app_id?: string | null;
    scope?: string;
    [key: string]: unknown;
}
interface AccountLinkPayloadOptions {
    localUserId: string;
    userInfo: TopoloUserInfo | TopoloIdTokenClaims;
    topoloContextId?: string | null;
    topoloAppId?: string | null;
    connectedAt?: string | Date;
    lastSeenAt?: string | Date;
}
interface TopoloAccountLinkPayload {
    local_user_id: string;
    topolo_user_id: string;
    topolo_org_id: string | null;
    topolo_context_id: string | null;
    topolo_app_id: string | null;
    connected_at: string;
    last_seen_at: string;
}
declare class TopoloLoginError extends Error {
    status?: number;
    payload?: unknown;
    oauthError?: string;
    constructor(message: string, options?: {
        status?: number;
        payload?: unknown;
        oauthError?: string;
    });
}
declare function createTopoloAuthorizationUrl(options: TopoloAuthorizationUrlOptions): Promise<TopoloAuthorizationUrlResult>;
declare function exchangeTopoloCode(options: ExchangeTopoloCodeOptions): Promise<TopoloTokenResponse>;
declare function refreshTopoloToken(options: RefreshTopoloTokenOptions): Promise<TopoloTokenResponse>;
declare function fetchTopoloUserInfo(options: FetchTopoloUserInfoOptions): Promise<TopoloUserInfo>;
declare function verifyTopoloIdToken(options: VerifyTopoloIdTokenOptions): Promise<VerifiedTopoloIdToken>;
declare function createAccountLinkPayload(options: AccountLinkPayloadOptions): TopoloAccountLinkPayload;
interface TopoloAuthJsProviderOptions {
    authBaseUrl?: string;
    clientId: string;
    clientSecret?: string;
    scopes?: readonly string[];
    id?: string;
    name?: string;
}
declare function createTopoloAuthJsProvider(options: TopoloAuthJsProviderOptions): Record<string, unknown>;
interface TopoloPassportStrategyOptions {
    authBaseUrl?: string;
    clientId: string;
    clientSecret?: string;
    callbackUrl: string;
    scopes?: readonly string[];
}
declare function createTopoloPassportStrategyConfig(options: TopoloPassportStrategyOptions): Record<string, unknown>;

export { type AccountLinkPayloadOptions, DEFAULT_TOPOLO_AUTH_ORIGIN, DEFAULT_TOPOLO_SCOPES, type ExchangeTopoloCodeOptions, type FetchLike, type FetchTopoloUserInfoOptions, type JsonWebKeySet, type RefreshTopoloTokenOptions, type TopoloAccountLinkPayload, type TopoloAuthJsProviderOptions, type TopoloAuthorizationUrlOptions, type TopoloAuthorizationUrlResult, type TopoloIdTokenClaims, type TopoloJsonWebKey, TopoloLoginError, type TopoloPassportStrategyOptions, type TopoloTokenRequestOptions, type TopoloTokenResponse, type TopoloUserInfo, type VerifiedTopoloIdToken, type VerifyTopoloIdTokenOptions, createAccountLinkPayload, createTopoloAuthJsProvider, createTopoloAuthorizationUrl, createTopoloPassportStrategyConfig, exchangeTopoloCode, fetchTopoloUserInfo, refreshTopoloToken, verifyTopoloIdToken };

Interfaces describe expected developer inputs/responses; they do not establish runtime validation, platform authorization or a real session. Preserve the live provider’s returned grant/refusal contracts and check actual token/profile values before use.

Evidence, verification and recovery

Published0.2.0 npm integrity is verified; ESM/CJS bundles and both declaration files reproduce exactly from source c3a35b9527c5243af77237b394b7053f2895f4d4. README matches; published manifest omits the source repository field. Source tests, build and typecheck pass. Measured source coverage is91.4% line/statements,96.07%branch and91.66%functions; the unmeasured ECDSA conversion branch is not represented as100% coverage. This guide adds 49 executed local captures including RSA/EC signature success, refused signatures/claims, transport forms/errors and every runtime export. None contacts a live issuer or registers/links an account.

For live integration proof, first use a reviewed development client and permitted redirect/scopes, verify callback state/nonce/session binding, validate response/required claims, use trusted issuer/JWKS and check token/session storage without printing credentials. Test refusal, expiration/refresh and explicit account linking. Prove the same application change in staging before production. API action execution after login must use fresh credential-scoped discovery and resource binding; a valid OAuth login alone is not proof of action permissions.

For401/invalid_token reauthenticate according to the actual issuer response. For invalid_grant do not guess a verifier or reuse an uncertain one-time code across parallel calls. For issuer/audience/nonce/signature failures reject the local login; investigate trusted client/config/JWKS rather than accepting unverified claims. Preserve safe status/refusal/request evidence without token bodies. There is no package rollback action or persisted mutation; host account-link/token/session changes use the host’s approved recovery contracts.