Posts

OAuth2.0 and OpenID Connect service for DN42 (oauth.dn42)

Introduction

We offer DN42 sign-in, third-party app authorization, self-service app management, and account settings.

Access

OIDC Discovery / Auto configuration file URL

Callback URL

  • You can only create/save callback URLs with "http" schema in oauth.dn42 (not clearnet auth.iedon.net)
  • "https" schema check for callback URLs is enabled in clearnet version of OAuth (auth.iedon.net)

Claims

Request the dn42 scope to include DN42 network information in your tokens. The dn42 claim contains the route prefixes originated by the authenticated AS, telephony prefixes registered under the active maintainer, and session-specific identity fields tied to the maintainer used at login.

Supported scopes: openid, profile, email, dn42

TLDR

  • Claim openid, email, profile, dn42
  • Use dn42.asn to get AS (Type: number)
  • Use dn42.active_mnt to get exact maintainer user logged-in with
  • Use dn42.active_name to get friendly name of active maintainer
  • Use dn42.route and dn42.route6 to get routes user is allowed to use
  • Use dn42.telephony to get registered telephony prefixes of active maintainer

The dn42 Claim

Field Type Description
asn number AS number of the authenticated user
route string[] IPv4 prefixes originated by this AS (real-time WHOIS)
route6 string[] IPv6 prefixes originated by this AS (real-time WHOIS)
timestamp number Unix timestamp when the WHOIS query was performed
active_mnt string The maintainer used to authenticate; see About Active Maintainer bellow
telephony string[] Telephony prefixes from telephony objects maintained by active_mnt; omitted if none exist
active_name string Display name of the person associated with active_mnt (ID Token only)
active_person string NIC handle of the person associated with active_mnt (ID Token only)
active_email string Email address used to authenticate; non-empty only when the email auth method was used (ID Token only)
auth_method string Authentication method used: passkey, password, email, pgp, or ssh (ID Token only)
mnt_by string[] All maintainers listed in the AS object's mnt-by field (ID Token only)

Fields marked ID Token only are present in the ID Token but not returned by the /userinfo endpoint. The UserInfo response for the dn42 claim contains only: asn, route, route6, timestamp, active_mnt, and telephony.

Email authentication: When the user signs in via the email method, active_email is set to the verified address used during the challenge, and the top-level email claim is populated from it directly. For all other auth methods, active_email is empty and the top-level email is sourced from the WHOIS e-mail field instead.

About Active Maintainer (active_mnt)

The active maintainer is the specific mntner object the user authenticated with during login.

A single AS can be maintained by multiple maintainer objects, and each (maintainer, AS) pair represents a distinct identity. For example, if AS4242420001 has mnt-by: ALICE-MNT, BOB-MNT, Alice and Bob each produce a different token even though both authenticate as the same AS:

Authenticated with active_mnt sub
ALICE-MNT + AS4242420001 ALICE-MNT sha256("ALICE-MNT/4242420001")
BOB-MNT + AS4242420001 BOB-MNT sha256("BOB-MNT/4242420001")

The sub claim is the lowercase hex-encoded SHA-256 digest of "<active_mnt>/<asn>". It stably and uniquely identifies the (maintainer, AS) pair and is guaranteed to be consistent between the ID Token and the /userinfo response.

Example ID Token Payload

Decoded payload for a login with scopes openid profile email dn42:

{
  "at_hash": "dgaDDz-MiPldBFG3o22jMQ",
  "aud": "3de6795a0ae65021d6b8cc3c7dfdc380",
  "auth_time": 1782007350,
  "dn42": {
    "active_email": "",
    "active_mnt": "IEDON-MNT",
    "active_name": "Name of Active Maintainer",
    "active_person": "IEDON-DN42",
    "asn": 4242422189,
    "auth_method": "password",
    "mnt_by": [
      "IEDON-MNT"
    ],
    "route": [
      "10.127.21.0/24",
      "10.127.25.0/24",
      "172.20.0.53/32",
      "172.23.0.80/32",
      "172.23.91.0/25",
      "172.23.91.128/26"
    ],
    "route6": [
      "fd42:4242:2189::/48",
      "fd42:d42:d42:54::/64",
      "fd42:d42:d42:80::/64"
    ],
    "telephony": [
      "+04242189"
    ],
    "timestamp": 1782007350
  },
  "email": "mail@active.maintainer.localdomain",
  "exp": 1782010950,
  "iat": 1782007350,
  "iss": "https://auth.iedon.net",
  "name": "Name of Active Maintainer",
  "nonce": "zX6fNEIHT9PUdCNargvZhA",
  "preferred_username": "AS4242422189",
  "sub": "sha256(MNT/AS)"
}

Notice

It is recommended to use /userinfo with access token to refresh/re-get user's dn42 claim, as a server background task and run this task in a period, because a user may register or return things from and back to the DN42 registry.

Example

JavaScript

import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import crypto from 'node:crypto'

// ── Configuration ──────────────────────────────────────────────
// Change these to match your auth42 backend and registered app
const PORT = 3002
const ISSUER = process.env.ISSUER || 'https://auth.iedon.net'
const CLIENT_ID = process.env.CLIENT_ID || 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
const CLIENT_SECRET = process.env.CLIENT_SECRET || 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`
const SCOPES = 'openid profile email dn42'

// ── PKCE helpers ───────────────────────────────────────────────
function base64url(buf) {
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

function generateCodeVerifier() {
  return base64url(crypto.randomBytes(32))
}

function generateCodeChallenge(verifier) {
  return base64url(crypto.createHash('sha256').update(verifier).digest())
}

// In-memory state (single-user demo, good enough for testing)
let pendingState = null
let pendingVerifier = null
let tokens = null

// ── App ────────────────────────────────────────────────────────
const app = new Hono()

// Home: show status + login link
app.get('/', (c) => {
  let html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>OIDC Tester</title>
  <style>body{font-family:system-ui;max-width:720px;margin:40px auto;padding:0 20px}
  pre{background:#f4f4f4;padding:16px;border-radius:8px;overflow-x:auto;white-space:pre-wrap;word-break:break-all}
  a{color:#1677ff}h1{border-bottom:2px solid #eee;padding-bottom:8px}
  .btn{display:inline-block;padding:10px 24px;background:#1677ff;color:#fff;text-decoration:none;border-radius:6px;margin:8px 4px 8px 0}
  .btn.red{background:#ff4d4f}</style></head><body>`
  html += `<h1>🔐 OIDC Tester</h1>`
  html += `<p><strong>Issuer:</strong> ${ISSUER}</p>`
  html += `<p><strong>Client ID:</strong> <code>${CLIENT_ID}</code></p>`
  html += `<p><strong>Redirect URI:</strong> <code>${REDIRECT_URI}</code></p>`

  if (CLIENT_ID === 'CHANGE_ME') {
    html += `<p style="color:red">⚠️ Set CLIENT_ID and CLIENT_SECRET env vars or edit index.js first!
    <br>Register an app in auth42, then set the values.</p>`
  }

  if (tokens) {
    html += `<h2>✅ Authenticated</h2>`
    html += `<p><a class="btn" href="/userinfo">Fetch /userinfo</a>`
    html += `<a class="btn" href="/refresh">Refresh Token</a>`
    html += `<a class="btn red" href="/logout">Logout (RP-Initiated)</a></p>`
    html += `<h3>Access Token</h3><pre>${tokens.access_token}</pre>`
    if (tokens.id_token) {
      html += `<h3>ID Token</h3><pre>${tokens.id_token}</pre>`
      try {
        const payload = JSON.parse(Buffer.from(tokens.id_token.split('.')[1], 'base64').toString())
        html += `<h3>ID Token Claims</h3><pre>${JSON.stringify(payload, null, 2)}</pre>`
      } catch {}
    }
    if (tokens.refresh_token) {
      html += `<h3>Refresh Token</h3><pre>${tokens.refresh_token}</pre>`
    }
    html += `<h3>Full Token Response</h3><pre>${JSON.stringify(tokens, null, 2)}</pre>`
  } else {
    html += `<p><a class="btn" href="/login">Login with OIDC</a></p>`
  }
  html += `</body></html>`
  return c.html(html)
})

// Step 1: Redirect to authorization endpoint
app.get('/login', (c) => {
  pendingState = base64url(crypto.randomBytes(16))
  pendingVerifier = generateCodeVerifier()
  const challenge = generateCodeChallenge(pendingVerifier)

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: SCOPES,
    state: pendingState,
    code_challenge: challenge,
    code_challenge_method: 'S256',
    nonce: base64url(crypto.randomBytes(16)),
  })

  return c.redirect(`${ISSUER}/authorize?${params}`)
})

// Step 2: Handle callback — exchange code for tokens
app.get('/callback', async (c) => {
  const code = c.req.query('code')
  const state = c.req.query('state')
  const error = c.req.query('error')

  if (error) {
    return c.html(`<h1>Error</h1><p>${error}: ${c.req.query('error_description') || ''}</p><p><a href="/">Back</a></p>`)
  }

  if (!code || !state) {
    return c.html(`<h1>Error</h1><p>Missing code or state</p><p><a href="/">Back</a></p>`)
  }

  if (state !== pendingState) {
    return c.html(`<h1>Error</h1><p>State mismatch</p><p><a href="/">Back</a></p>`)
  }

  // Exchange authorization code for tokens
  try {
    const body = new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code_verifier: pendingVerifier,
    })

    const resp = await fetch(`${ISSUER}/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: body.toString(),
    })

    if (!resp.ok) {
      const errBody = await resp.text()
      return c.html(`<h1>Token Error (${resp.status})</h1><pre>${errBody}</pre><p><a href="/">Back</a></p>`)
    }

    tokens = await resp.json()
    pendingState = null
    pendingVerifier = null
    return c.redirect('/')
  } catch (err) {
    return c.html(`<h1>Error</h1><pre>${err.message}</pre><p><a href="/">Back</a></p>`)
  }
})

// Fetch userinfo
app.get('/userinfo', async (c) => {
  if (!tokens?.access_token) return c.redirect('/')

  try {
    const resp = await fetch(`${ISSUER}/userinfo`, {
      headers: { Authorization: `Bearer ${tokens.access_token}` },
    })
    const data = await resp.json()
    let html = `<h1>UserInfo Response (${resp.status})</h1>`
    html += `<pre>${JSON.stringify(data, null, 2)}</pre>`
    html += `<p><a href="/">Back</a></p>`
    return c.html(html)
  } catch (err) {
    return c.html(`<h1>Error</h1><pre>${err.message}</pre><p><a href="/">Back</a></p>`)
  }
})

// Refresh token
app.get('/refresh', async (c) => {
  if (!tokens?.refresh_token) {
    return c.html(`<h1>No Refresh Token</h1><p><a href="/">Back</a></p>`)
  }

  try {
    const body = new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: tokens.refresh_token,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    })

    const resp = await fetch(`${ISSUER}/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: body.toString(),
    })

    const data = await resp.json()
    if (resp.ok) {
      tokens = { ...tokens, ...data }
    }
    let html = `<h1>Refresh Response (${resp.status})</h1>`
    html += `<pre>${JSON.stringify(data, null, 2)}</pre>`
    html += `<p><a href="/">Back</a></p>`
    return c.html(html)
  } catch (err) {
    return c.html(`<h1>Error</h1><pre>${err.message}</pre><p><a href="/">Back</a></p>`)
  }
})

// RP-Initiated Logout
app.get('/logout', (c) => {
  const params = new URLSearchParams()
  if (tokens?.id_token) {
    params.set('id_token_hint', tokens.id_token)
  }
  params.set('post_logout_redirect_uri', `http://127.0.0.1:${PORT}/logged-out`)
  params.set('client_id', CLIENT_ID)

  tokens = null
  return c.redirect(`${ISSUER}/logout?${params}`)
})

app.get('/logged-out', (c) => {
  return c.html(`<h1>Logged Out</h1><p>Session ended.</p><p><a href="/">Back to Home</a></p>`)
})

// ── Start ──────────────────────────────────────────────────────
serve({ fetch: app.fetch, port: PORT }, () => {
  console.log(`OIDC Tester running at http://127.0.0.1:${PORT}`)
  console.log(`Issuer: ${ISSUER}`)
  console.log(`Client ID: ${CLIENT_ID}`)
  if (CLIENT_ID === 'CHANGE_ME') {
    console.log('\n⚠️  Set CLIENT_ID and CLIENT_SECRET before testing!')
    console.log('   Register an app in the auth42 admin, then:')
    console.log(`   CLIENT_ID=xxx CLIENT_SECRET=yyy node index.js\n`)
  }
})