| … | … | @@ -154,7 +154,8 @@ export class AuthManager { |
| 154 | 154 | user, |
| 155 | 155 | verified: true |
| 156 | 156 | } |
| 157 | } catch { | |
| 157 | } catch (err) { | |
| 158 | console.error(`[AuthManager] Provider ${provider.name} error:`, err.message) | |
| 158 | 159 | continue |
| 159 | 160 | } |
| 160 | 161 | } |
| … | … | @@ -162,6 +163,15 @@ export class AuthManager { |
| 162 | 163 | return null |
| 163 | 164 | } |
| 164 | 165 | |
| 166 | /** | |
| 167 | * Generate a challenge for challenge-response auth | |
| 168 | * @param {Object} opts - { publicKey?, metadata? } | |
| 169 | * @returns {Promise<Object>} { challengeId, challenge, expiresAt } | |
| 170 | */ | |
| 171 | async generateChallenge (opts = {}) { | |
| 172 | return this.challengeManager.generate(opts) | |
| 173 | } | |
| 174 | ||
| 165 | 175 | /** |
| 166 | 176 | * Format user permissions |
| 167 | 177 | * Uses user.resources (generic) instead of application-specific terms |
| … | … | @@ -23,7 +23,7 @@ export class ChallengeManager { |
| 23 | 23 | */ |
| 24 | 24 | async generate (opts = {}) { |
| 25 | 25 | const challengeId = b4a.toString(crypto.randomBytes(16), 'hex') |
| 26 | const challenge = b4a.toString(crypto.randomBytes(32), 'hex') | |
| 26 | const challenge = b4a.toString(crypto.randomBytes(32), 'base64') | |
| 27 | 27 | const expiresAt = Date.now() + this.ttl |
| 28 | 28 | |
| 29 | 29 | const entry = { |
| … | … | @@ -47,12 +47,11 @@ export class ChallengeManager { |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | 49 | /** |
| 50 | * Verify and consume a challenge | |
| 51 | * @param {string} challengeId - Challenge ID | |
| 52 | * @param {string} challenge - Challenge value to verify | |
| 53 | * @returns {Promise<Object|null>} Challenge entry or null | |
| 50 | * Get a challenge by ID (without consuming it) | |
| 51 | * @param {string} challengeId | |
| 52 | * @returns {Promise<Object|null>} Challenge entry or null if expired/consumed | |
| 54 | 53 | */ |
| 55 | async verify (challengeId, challenge) { | |
| 54 | async get (challengeId) { | |
| 56 | 55 | let entry = this._cache.get(challengeId) |
| 57 | 56 | |
| 58 | 57 | if (!entry && this.db) { |
| … | … | @@ -63,15 +62,41 @@ export class ChallengeManager { |
| 63 | 62 | if (!entry) return null |
| 64 | 63 | if (entry.consumed) return null |
| 65 | 64 | if (entry.expiresAt < Date.now()) return null |
| 66 | if (entry.challenge !== challenge) return null | |
| 67 | 65 | |
| 68 | // Mark consumed | |
| 69 | entry.consumed = true | |
| 70 | this._cache.set(challengeId, entry) | |
| 66 | return entry | |
| 67 | } | |
| 68 | ||
| 69 | /** | |
| 70 | * Consume a challenge (mark as used, single-use) | |
| 71 | * @param {string} challengeId | |
| 72 | */ | |
| 73 | async consume (challengeId) { | |
| 74 | const entry = this._cache.get(challengeId) | |
| 75 | if (entry) { | |
| 76 | entry.consumed = true | |
| 77 | this._cache.set(challengeId, entry) | |
| 78 | } | |
| 71 | 79 | if (this.db) { |
| 72 | await this.db.put(`challenge:${challengeId}`, entry) | |
| 80 | const stored = await this.db.get(`challenge:${challengeId}`) | |
| 81 | if (stored) { | |
| 82 | stored.value.consumed = true | |
| 83 | await this.db.put(`challenge:${challengeId}`, stored.value) | |
| 84 | } | |
| 73 | 85 | } |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * Verify and consume a challenge (combined operation) | |
| 90 | * @param {string} challengeId - Challenge ID | |
| 91 | * @param {string} challenge - Challenge value to verify | |
| 92 | * @returns {Promise<Object|null>} Challenge entry or null | |
| 93 | */ | |
| 94 | async verify (challengeId, challenge) { | |
| 95 | const entry = await this.get(challengeId) | |
| 96 | if (!entry) return null | |
| 97 | if (entry.challenge !== challenge) return null | |
| 74 | 98 | |
| 99 | await this.consume(challengeId) | |
| 75 | 100 | return entry |
| 76 | 101 | } |
| 77 | 102 |
| … | … | @@ -1,6 +1,5 @@ |
| 1 | 1 | import { BaseProvider } from '../base-provider.js' |
| 2 | 2 | import { ANProto } from '../../anproto.js' |
| 3 | import b4a from 'b4a' | |
| 4 | 3 | |
| 5 | 4 | /** |
| 6 | 5 | * WebIdentityProvider — Browser ANProto authentication |
| … | … | @@ -29,48 +28,30 @@ export class WebIdentityProvider extends BaseProvider { |
| 29 | 28 | const { publicKey, signature, challengeId } = credentials |
| 30 | 29 | |
| 31 | 30 | if (signature && challengeId) { |
| 32 | // Signed auth — verify challenge signature | |
| 33 | const challenge = await this.challengeManager.verify(challengeId, challengeId) | |
| 34 | if (!challenge) { | |
| 35 | // Try direct challenge verification | |
| 36 | const stored = await this._getChallenge(challengeId) | |
| 37 | if (!stored) throw new Error('Invalid or expired challenge') | |
| 31 | // Look up the challenge by ID | |
| 32 | const challenge = await this.challengeManager.get(challengeId) | |
| 33 | if (!challenge) throw new Error('Invalid or expired challenge') | |
| 38 | 34 | |
| 39 | const valid = ANProto.verify(stored.challenge, signature, publicKey) | |
| 40 | if (!valid) throw new Error('Invalid signature') | |
| 41 | } else { | |
| 42 | const valid = ANProto.verify(challenge.challenge, signature, publicKey) | |
| 43 | if (!valid) throw new Error('Invalid signature') | |
| 44 | } | |
| 35 | // Verify signature against the challenge value | |
| 36 | const valid = ANProto.verify(challenge.challenge, signature, publicKey) | |
| 37 | if (!valid) throw new Error('Invalid signature') | |
| 38 | ||
| 39 | // Consume the challenge (single-use) | |
| 40 | await this.challengeManager.consume(challengeId) | |
| 45 | 41 | } |
| 46 | 42 | |
| 47 | 43 | // Look up or auto-register identity |
| 48 | 44 | let identity = await this.registry.getIdentityByPublicKey(publicKey) |
| 49 | 45 | |
| 50 | 46 | if (!identity) { |
| 51 | // Auto-register new web identity | |
| 52 | const identityId = publicKey // Use public key as identity ID | |
| 53 | await this.registry.registerIdentity({ | |
| 54 | identityId, | |
| 47 | // Auto-register: creates user + identity atomically | |
| 48 | // First user gets admin, subsequent get read-only | |
| 49 | const result = await this.registry.autoRegister({ | |
| 50 | identityId: publicKey, | |
| 55 | 51 | method: 'web-identity', |
| 56 | publicKey, | |
| 57 | createdAt: Date.now() | |
| 58 | }) | |
| 59 | ||
| 60 | // Auto-create user | |
| 61 | const user = await this.registry.createUser({ | |
| 62 | name: 'Anonymous', | |
| 63 | globalPermissions: ['read'], | |
| 64 | resources: [{ name: '*', permissions: ['read'] }], | |
| 65 | identities: [identityId] | |
| 52 | credentials: { publicKey } | |
| 66 | 53 | }) |
| 67 | ||
| 68 | // Link identity to user | |
| 69 | if (user && user.userId) { | |
| 70 | await this.registry.linkIdentityToUser(identityId, user.userId) | |
| 71 | } | |
| 72 | ||
| 73 | identity = await this.registry.getIdentity(identityId) | |
| 54 | identity = result.identity | |
| 74 | 55 | } |
| 75 | 56 | |
| 76 | 57 | return { |
| … | … | @@ -79,14 +60,6 @@ export class WebIdentityProvider extends BaseProvider { |
| 79 | 60 | credentials: { publicKey } |
| 80 | 61 | } |
| 81 | 62 | } |
| 82 | ||
| 83 | async _getChallenge (challengeId) { | |
| 84 | try { | |
| 85 | return await this.challengeManager.verify(challengeId, challengeId) | |
| 86 | } catch { | |
| 87 | return null | |
| 88 | } | |
| 89 | } | |
| 90 | 63 | } |
| 91 | 64 | |
| 92 | 65 | export default WebIdentityProvider |