AppView in a box as a Vite plugin thing hatk.dev
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: support prompt=create in PAR for account creation

Accept prompt parameter in handlePar. When prompt=create, treat
login_hint as a PDS hostname, discover auth server via protected
resource metadata, and forward prompt=create to the PDS PAR request.
Handles bare hostnames (localhost:* -> http://, others -> https://).

Add documentation for native app clients and account creation flow.
Bump version to 0.0.1-alpha.47.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+99 -18
+45
docs/site/guides/auth.md
··· 142 142 {/if} 143 143 ``` 144 144 145 + ## Native app clients 146 + 147 + hatk supports native app OAuth clients (iOS, Android, etc.) that use a custom URL scheme for redirects and communicate directly with the PAR and token endpoints. 148 + 149 + ### Client configuration 150 + 151 + Register a native client in `hatk.config.ts` using a custom scheme `client_id` and `redirect_uri`: 152 + 153 + ```typescript 154 + clients: [ 155 + // Native iOS app 156 + { 157 + client_id: "my-app://app", 158 + client_name: "my-app-native", 159 + scope: "atproto repo:xyz.statusphere.status?action=create", 160 + redirect_uris: ["my-app://oauth/callback"], 161 + }, 162 + ], 163 + ``` 164 + 165 + ### Account creation 166 + 167 + Native clients can trigger account creation by sending `prompt=create` in the PAR request. When `prompt=create` is set, the `login_hint` parameter is treated as a **PDS hostname** (not a handle or DID), since the user doesn't have an account yet. 168 + 169 + ``` 170 + POST /oauth/par 171 + Content-Type: application/x-www-form-urlencoded 172 + 173 + client_id=my-app://app 174 + &redirect_uri=my-app://oauth/callback 175 + &response_type=code 176 + &code_challenge=<challenge> 177 + &code_challenge_method=S256 178 + &scope=atproto 179 + &prompt=create 180 + &login_hint=selfhosted.social 181 + ``` 182 + 183 + The `login_hint` should be the bare hostname of the PDS where the account will be created: 184 + 185 + - **Production:** `selfhosted.social` (or your PDS domain) 186 + - **Local development:** `localhost:2583` 187 + 188 + hatk will automatically prepend the correct scheme (`https://` for production, `http://` for localhost) and discover the PDS auth server via its protected resource metadata. The `prompt=create` parameter is forwarded to the PDS so it shows the signup page instead of the login page. 189 + 145 190 ## Server-side auth 146 191 147 192 ### `parseViewer` in layouts
+1 -1
packages/hatk/package.json
··· 1 1 { 2 2 "name": "@hatk/hatk", 3 - "version": "0.0.1-alpha.46", 3 + "version": "0.0.1-alpha.47", 4 4 "license": "MIT", 5 5 "bin": { 6 6 "hatk": "dist/cli.js"
+53 -17
packages/hatk/src/oauth/server.ts
··· 16 16 import { parseDpopProof, createDpopProof } from './dpop.ts' 17 17 import { initSession } from './session.ts' 18 18 import { resolveClient, validateRedirectUri, isLoopbackClient } from './client.ts' 19 - import { discoverAuthServer, resolveHandle } from './discovery.ts' 19 + import { discoverAuthServer, resolveHandle, fetchProtectedResourceMetadata, fetchAuthServerMetadata } from './discovery.ts' 20 20 import { 21 21 getServerKey, 22 22 storeServerKey, ··· 165 165 166 166 // --- PAR Endpoint --- 167 167 168 + /** 169 + * Handle a Pushed Authorization Request (PAR). 170 + * 171 + * Supports account creation via `prompt=create`. When set, `login_hint` 172 + * is treated as a PDS hostname (e.g. "selfhosted.social" or "localhost:2583") 173 + * rather than a handle or DID. The auth server is discovered from the PDS's 174 + * protected resource metadata, and `prompt=create` is forwarded to the PDS 175 + * PAR so it shows the signup page. 176 + * 177 + * For normal login, `login_hint` is a handle or DID as usual. 178 + */ 168 179 export async function handlePar( 169 180 config: OAuthConfig, 170 181 body: Record<string, string>, ··· 191 202 if (!body.code_challenge) throw new Error('code_challenge is required') 192 203 if (body.code_challenge_method && body.code_challenge_method !== 'S256') throw new Error('Only S256 supported') 193 204 194 - // Resolve DID from login_hint 195 - let did = body.login_hint 196 - if (did && !did.startsWith('did:')) { 205 + // Resolve DID and PDS from login_hint 206 + const prompt = body.prompt 207 + let did: string | undefined = body.login_hint 208 + let pdsRequestUri: string | undefined 209 + let pdsAuthServer: string | undefined 210 + let pdsCodeVerifier: string | undefined 211 + let pdsState: string | undefined 212 + let pdsEndpoint: string | undefined 213 + 214 + if (prompt === 'create' && body.login_hint) { 215 + // Account creation: login_hint is a PDS URL, discover auth server from it directly 216 + let pdsUrl: string 217 + if (body.login_hint.startsWith('http')) { 218 + pdsUrl = body.login_hint 219 + } else if (body.login_hint.match(/^localhost[:/]/)) { 220 + pdsUrl = `http://${body.login_hint}` 221 + } else { 222 + pdsUrl = `https://${body.login_hint}` 223 + } 224 + pdsEndpoint = pdsUrl 225 + const protectedResource = await fetchProtectedResourceMetadata(pdsUrl) 226 + pdsAuthServer = protectedResource.authorization_servers[0] 227 + if (!pdsAuthServer) throw new Error(`No auth server for PDS ${pdsUrl}`) 228 + did = undefined // no DID yet for account creation 229 + } else if (did && !did.startsWith('did:')) { 197 230 try { 198 231 did = await resolveHandle(did, _relayUrl) 199 232 } catch { ··· 201 234 } 202 235 } 203 236 204 - // Discover user's PDS auth server 205 - let pdsRequestUri: string | undefined 206 - let pdsAuthServer: string | undefined 207 - let pdsCodeVerifier: string | undefined 208 - let pdsState: string | undefined 209 - 210 - let pdsEndpoint: string | undefined 211 - 212 - if (did) { 237 + // Discover user's PDS auth server (for login flow with a resolved DID) 238 + if (did && !pdsAuthServer) { 213 239 const discovery = await discoverAuthServer(did, _plcUrl) 214 240 pdsAuthServer = discovery.authServerEndpoint 215 241 pdsEndpoint = discovery.pdsEndpoint 242 + } 243 + 244 + if (pdsAuthServer) { 245 + const authServerMetadata = await fetchAuthServerMetadata(pdsAuthServer) 216 246 217 247 // Create PKCE for our PAR to the PDS 218 248 pdsCodeVerifier = randomToken() ··· 221 251 222 252 // PAR to the PDS 223 253 const parEndpoint = 224 - discovery.authServerMetadata.pushed_authorization_request_endpoint || `${pdsAuthServer}/oauth/par` 254 + authServerMetadata.pushed_authorization_request_endpoint || `${pdsAuthServer}/oauth/par` 225 255 const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint) 226 256 227 - const pdsParBody = new URLSearchParams({ 257 + const pdsParParams: Record<string, string> = { 228 258 client_id: pdsClientId(config.issuer, config), 229 259 redirect_uri: pdsRedirectUri(config.issuer), 230 260 response_type: 'code', 231 261 code_challenge: pdsCodeChallenge, 232 262 code_challenge_method: 'S256', 233 263 scope: body.scope || 'atproto transition:generic', 234 - login_hint: body.login_hint || did, 235 264 state: pdsState, 236 - }) 265 + } 266 + if (prompt === 'create') { 267 + pdsParParams.prompt = 'create' 268 + } 269 + if (did) { 270 + pdsParParams.login_hint = body.login_hint || did 271 + } 272 + const pdsParBody = new URLSearchParams(pdsParParams) 237 273 238 274 const pdsParRes = await fetch(parEndpoint, { 239 275 method: 'POST',