' )
+ .append( $( '| ' ).text( passkey.name || mw.msg( 'passkeyauth-managepasskeys-unnamed' ) ) )
+ .append( $( ' | ' ).text( mw.passkeyAuth.formatTimestamp( passkey.created ) ) )
+ .append( $( ' | ' ).text( mw.passkeyAuth.formatTimestamp( passkey.lastUsed ) ) )
+ .append( $( ' | ' ).append( deleteButton.$element ) );
+
+ $tbody.append( $row );
+ } );
+
+ $table.append( $thead ).append( $tbody );
+ this.$element.append( $table );
+ };
+
+ /**
+ * Handle delete button click
+ *
+ * @param {number} passkeyId
+ * @param {OO.ui.ButtonWidget} button
+ */
+ mw.passkeyAuth.PasskeyListWidget.prototype.onDeleteClick = function ( passkeyId, button ) {
+ var widget = this;
+
+ if ( !confirm( mw.msg( 'passkeyauth-managepasskeys-delete-confirm' ) ) ) {
+ return;
+ }
+
+ button.setDisabled( true );
+
+ this.api.deletePasskey( passkeyId )
+ .then( function () {
+ mw.passkeyAuth.showNotification(
+ mw.msg( 'passkeyauth-managepasskeys-delete-success' ),
+ 'success'
+ );
+ // Remove from local array
+ widget.passkeys = widget.passkeys.filter( function ( p ) {
+ return p.id !== passkeyId;
+ } );
+ widget.render();
+ } )
+ .catch( function ( error ) {
+ mw.passkeyAuth.showNotification(
+ mw.msg( 'passkeyauth-managepasskeys-delete-error' ),
+ 'error'
+ );
+ button.setDisabled( false );
+ } );
+ };
+
+}() );
diff --git a/modules/ext.passkeyAuth.managePasskeys/init.js b/modules/ext.passkeyAuth.managePasskeys/init.js
new file mode 100644
index 0000000..d480c41
--- /dev/null
+++ b/modules/ext.passkeyAuth.managePasskeys/init.js
@@ -0,0 +1,15 @@
+/**
+ * Initialize the Manage Passkeys widget
+ */
+( function () {
+ 'use strict';
+
+ $( function () {
+ var $container = $( '#passkeyauth-manage-widget' );
+ if ( $container.length ) {
+ var widget = new mw.passkeyAuth.ManagePasskeysWidget();
+ $container.append( widget.$element );
+ }
+ } );
+
+}() );
diff --git a/modules/ext.passkeyAuth.managePasskeys/styles.less b/modules/ext.passkeyAuth.managePasskeys/styles.less
new file mode 100644
index 0000000..b2ac2e2
--- /dev/null
+++ b/modules/ext.passkeyAuth.managePasskeys/styles.less
@@ -0,0 +1,21 @@
+.passkeyauth-manage-widget {
+ max-width: 800px;
+ margin: 20px 0;
+
+ .passkeyauth-table {
+ width: 100%;
+
+ th, td {
+ padding: 8px;
+ text-align: left;
+ }
+
+ td:last-child {
+ text-align: right;
+ }
+ }
+}
+
+.passkeyauth-list-widget {
+ margin-top: 20px;
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..a19bc62
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "passkey-auth",
+ "version": "1.0.0",
+ "description": "MediaWiki extension providing WebAuthn/passkey authentication",
+ "private": true,
+ "scripts": {
+ "test": "grunt test",
+ "doc": "jsdoc -c jsdoc.json"
+ },
+ "devDependencies": {
+ "eslint-config-wikimedia": "0.28.2",
+ "grunt": "1.6.1",
+ "grunt-banana-checker": "0.13.0",
+ "grunt-eslint": "24.3.0",
+ "grunt-stylelint": "0.20.1",
+ "stylelint-config-wikimedia": "0.17.2"
+ }
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..e29bae9
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,19 @@
+
+
+
+
+ tests/phpunit/unit
+
+
+
+
+ src
+
+
+
diff --git a/sql/gensql.sh b/sql/gensql.sh
new file mode 100644
index 0000000..9967b7d
--- /dev/null
+++ b/sql/gensql.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+dir=`dirname "$0"`
+echo $dir
+for db in mysql postgres sqlite
+do
+ for schema in PasskeyAuth
+ do
+ echo $db : $schema
+
+ php $dir/../../../maintenance/generateSchemaSql.php --json $schema.json --sql $db/$schema.sql --type=$db
+ done
+done
\ No newline at end of file
diff --git a/sql/mysql/PasskeyAuth.sql b/sql/mysql/PasskeyAuth.sql
new file mode 100644
index 0000000..b6bce46
--- /dev/null
+++ b/sql/mysql/PasskeyAuth.sql
@@ -0,0 +1,16 @@
+CREATE TABLE /*_*/passkey_credentials (
+ `pc_id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Primary key',
+ `pc_user` INT UNSIGNED NOT NULL COMMENT 'User ID from user table',
+ `pc_credential_id` VARCHAR(255) NOT NULL COMMENT 'WebAuthn credential ID',
+ `pc_public_key` BLOB NOT NULL COMMENT 'Public key data',
+ `pc_name` VARCHAR(255) NULL DEFAULT NULL COMMENT 'User-friendly name for the passkey',
+ `pc_counter` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Signature counter for replay protection',
+ -- MediaWiki-style timestamp (YYYYMMDDHHMMSS)
+ `pc_created` VARBINARY(14) NOT NULL COMMENT 'Creation timestamp',
+ `pc_last_used` VARBINARY(14) NULL DEFAULT NULL COMMENT 'Last usage timestamp',
+ `pc_user_agent` VARCHAR(255) NULL DEFAULT NULL COMMENT 'User agent when created',
+ PRIMARY KEY (`pc_id`),
+ UNIQUE KEY `pc_credential_id` (`pc_credential_id`),
+ KEY `pc_user` (`pc_user`),
+ KEY `pc_user_created` (`pc_user`, `pc_created`)
+) /*$wgDBTableOptions*/;
diff --git a/sql/tables.json b/sql/tables.json
new file mode 100644
index 0000000..f771cf6
--- /dev/null
+++ b/sql/tables.json
@@ -0,0 +1,114 @@
+[
+ {
+ "name": "passkey_credentials",
+ "comment": "Stores WebAuthn passkey credentials for users",
+ "columns": [
+ {
+ "name": "pc_id",
+ "type": "integer",
+ "options": {
+ "unsigned": true,
+ "notnull": true,
+ "autoincrement": true
+ }
+ },
+ {
+ "name": "pc_user",
+ "comment": "User ID from user table",
+ "type": "integer",
+ "options": {
+ "unsigned": true,
+ "notnull": true
+ }
+ },
+ {
+ "name": "pc_credential_id",
+ "comment": "WebAuthn credential ID",
+ "type": "string",
+ "options": {
+ "length": 255,
+ "notnull": true
+ }
+ },
+ {
+ "name": "pc_public_key",
+ "comment": "Public key data",
+ "type": "blob",
+ "options": {
+ "notnull": true
+ }
+ },
+ {
+ "name": "pc_name",
+ "comment": "User-friendly name for the passkey",
+ "type": "string",
+ "options": {
+ "length": 255,
+ "notnull": false
+ }
+ },
+ {
+ "name": "pc_counter",
+ "comment": "Signature counter for replay protection",
+ "type": "integer",
+ "options": {
+ "unsigned": true,
+ "notnull": true,
+ "default": 0
+ }
+ },
+ {
+ "name": "pc_created",
+ "comment": "Creation timestamp",
+ "type": "mwtimestamp",
+ "options": {
+ "notnull": true
+ }
+ },
+ {
+ "name": "pc_last_used",
+ "comment": "Last usage timestamp",
+ "type": "mwtimestamp",
+ "options": {
+ "notnull": false
+ }
+ },
+ {
+ "name": "pc_user_agent",
+ "comment": "User agent when created",
+ "type": "string",
+ "options": {
+ "length": 255,
+ "notnull": false
+ }
+ }
+ ],
+ "indexes": [
+ {
+ "name": "pc_user",
+ "columns": [
+ "pc_user"
+ ],
+ "unique": false
+ },
+ {
+ "name": "pc_credential_id",
+ "columns": [
+ "pc_credential_id"
+ ],
+ "unique": true
+ },
+ {
+ "name": "pc_user_created",
+ "columns": [
+ "pc_user",
+ "pc_created"
+ ],
+ "unique": false
+ }
+ ],
+ "pk": [
+ "pc_id"
+ ]
+ }
+]
diff --git a/src/Auth/PasskeyAuthenticationPlugin.php b/src/Auth/PasskeyAuthenticationPlugin.php
new file mode 100644
index 0000000..2e56134
--- /dev/null
+++ b/src/Auth/PasskeyAuthenticationPlugin.php
@@ -0,0 +1,45 @@
+passkeyService = $passkeyService;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function authenticate( ?int &$id, ?string &$username, ?string &$realname, ?string &$email, ?string &$errorMessage ): bool {
+ // Authentication is handled by REST API
+ // This method is called after successful authentication
+ return true;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function deauthenticate( \User &$user ): void {
+ // No special deauthentication needed
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function saveExtraAttributes( int $id ): void {
+ // No extra attributes to save
+ }
+}
diff --git a/src/Auth/PasskeyBackchannelLogoutPlugin.php b/src/Auth/PasskeyBackchannelLogoutPlugin.php
new file mode 100644
index 0000000..046c3e0
--- /dev/null
+++ b/src/Auth/PasskeyBackchannelLogoutPlugin.php
@@ -0,0 +1,35 @@
+logger = $logger;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function onUserLogoutComplete( $user, &$inject_html, $oldName ) {
+ $this->logger->debug( 'User logged out', [
+ 'userId' => $user->getId(),
+ 'userName' => $oldName,
+ ] );
+
+ // Clear any passkey-related session data if needed
+ // For now, standard MediaWiki logout handling is sufficient
+ }
+}
diff --git a/src/DataAccess/IPasskeyStore.php b/src/DataAccess/IPasskeyStore.php
new file mode 100644
index 0000000..09c3cef
--- /dev/null
+++ b/src/DataAccess/IPasskeyStore.php
@@ -0,0 +1,76 @@
+dbProvider = $dbProvider;
+ $this->logger = $logger;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function createPasskey( Passkey $passkey ): StatusValue {
+ $dbw = $this->dbProvider->getConnection( DB_PRIMARY );
+
+ try {
+ $dbw->newInsertQueryBuilder()
+ ->insertInto( 'passkey_credentials' )
+ ->row( [
+ 'pc_user' => $passkey->getUserId(),
+ 'pc_credential_id' => $passkey->getCredentialId(),
+ 'pc_public_key' => $passkey->getPublicKey(),
+ 'pc_name' => $passkey->getName(),
+ 'pc_counter' => $passkey->getCounter(),
+ 'pc_created' => $passkey->getCreated(),
+ 'pc_last_used' => $passkey->getLastUsed(),
+ 'pc_user_agent' => $passkey->getUserAgent(),
+ ] )
+ ->caller( __METHOD__ )
+ ->execute();
+
+ $passkey->setId( $dbw->insertId() );
+
+ $this->logger->info( 'Passkey created', [
+ 'userId' => $passkey->getUserId(),
+ 'credentialId' => $passkey->getCredentialId(),
+ ] );
+
+ return StatusValue::newGood( $passkey );
+ } catch ( \Exception $e ) {
+ $this->logger->error( 'Failed to create passkey', [
+ 'error' => $e->getMessage(),
+ 'userId' => $passkey->getUserId(),
+ ] );
+
+ return StatusValue::newFatal( 'passkeyauth-error-create-failed' );
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getPasskeyByCredentialId( string $credentialId ): ?Passkey {
+ $dbr = $this->dbProvider->getConnection( DB_REPLICA );
+
+ $row = $dbr->newSelectQueryBuilder()
+ ->select( '*' )
+ ->from( 'passkey_credentials' )
+ ->where( [ 'pc_credential_id' => $credentialId ] )
+ ->caller( __METHOD__ )
+ ->fetchRow();
+
+ if ( !$row ) {
+ return null;
+ }
+
+ return Passkey::newFromRow( $row );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getPasskeyById( int $id ): ?Passkey {
+ $dbr = $this->dbProvider->getConnection( DB_REPLICA );
+
+ $row = $dbr->newSelectQueryBuilder()
+ ->select( '*' )
+ ->from( 'passkey_credentials' )
+ ->where( [ 'pc_id' => $id ] )
+ ->caller( __METHOD__ )
+ ->fetchRow();
+
+ if ( !$row ) {
+ return null;
+ }
+
+ return Passkey::newFromRow( $row );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getPasskeysByUserId( int $userId ): array {
+ $dbr = $this->dbProvider->getConnection( DB_REPLICA );
+
+ $result = $dbr->newSelectQueryBuilder()
+ ->select( '*' )
+ ->from( 'passkey_credentials' )
+ ->where( [ 'pc_user' => $userId ] )
+ ->orderBy( 'pc_created', 'DESC' )
+ ->caller( __METHOD__ )
+ ->fetchResultSet();
+
+ $passkeys = [];
+ foreach ( $result as $row ) {
+ $passkeys[] = Passkey::newFromRow( $row );
+ }
+
+ return $passkeys;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function updatePasskey( Passkey $passkey ): StatusValue {
+ $dbw = $this->dbProvider->getConnection( DB_PRIMARY );
+
+ try {
+ $dbw->newUpdateQueryBuilder()
+ ->update( 'passkey_credentials' )
+ ->set( [
+ 'pc_name' => $passkey->getName(),
+ 'pc_counter' => $passkey->getCounter(),
+ 'pc_last_used' => $passkey->getLastUsed(),
+ ] )
+ ->where( [ 'pc_id' => $passkey->getId() ] )
+ ->caller( __METHOD__ )
+ ->execute();
+
+ $this->logger->info( 'Passkey updated', [
+ 'id' => $passkey->getId(),
+ 'userId' => $passkey->getUserId(),
+ ] );
+
+ return StatusValue::newGood();
+ } catch ( \Exception $e ) {
+ $this->logger->error( 'Failed to update passkey', [
+ 'error' => $e->getMessage(),
+ 'id' => $passkey->getId(),
+ ] );
+
+ return StatusValue::newFatal( 'passkeyauth-error-update-failed' );
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function deletePasskey( int $id ): StatusValue {
+ $dbw = $this->dbProvider->getConnection( DB_PRIMARY );
+
+ try {
+ $dbw->newDeleteQueryBuilder()
+ ->deleteFrom( 'passkey_credentials' )
+ ->where( [ 'pc_id' => $id ] )
+ ->caller( __METHOD__ )
+ ->execute();
+
+ $this->logger->info( 'Passkey deleted', [
+ 'id' => $id,
+ ] );
+
+ return StatusValue::newGood();
+ } catch ( \Exception $e ) {
+ $this->logger->error( 'Failed to delete passkey', [
+ 'error' => $e->getMessage(),
+ 'id' => $id,
+ ] );
+
+ return StatusValue::newFatal( 'passkeyauth-error-delete-failed' );
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function countPasskeysByUserId( int $userId ): int {
+ $dbr = $this->dbProvider->getConnection( DB_REPLICA );
+
+ return (int)$dbr->newSelectQueryBuilder()
+ ->select( 'COUNT(*)' )
+ ->from( 'passkey_credentials' )
+ ->where( [ 'pc_user' => $userId ] )
+ ->caller( __METHOD__ )
+ ->fetchField();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function userHasPasskeys( int $userId ): bool {
+ return $this->countPasskeysByUserId( $userId ) > 0;
+ }
+}
diff --git a/src/Hook/BeforePageDisplay.php b/src/Hook/BeforePageDisplay.php
new file mode 100644
index 0000000..9840011
--- /dev/null
+++ b/src/Hook/BeforePageDisplay.php
@@ -0,0 +1,23 @@
+getTitle() && $out->getTitle()->isSpecial( 'Userlogin' ) ) {
+ $out->addModules( 'ext.passkeyAuth.login' );
+ }
+ }
+}
diff --git a/src/Hook/HookRunner.php b/src/Hook/HookRunner.php
new file mode 100644
index 0000000..357a9ee
--- /dev/null
+++ b/src/Hook/HookRunner.php
@@ -0,0 +1,33 @@
+hookContainer = $hookContainer;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function onPasskeyAuthUserAuthorization( $user, &$authorized ) {
+ return $this->hookContainer->run(
+ 'PasskeyAuthUserAuthorization',
+ [ $user, &$authorized ]
+ );
+ }
+}
diff --git a/src/Hook/LoadExtensionSchemaUpdates.php b/src/Hook/LoadExtensionSchemaUpdates.php
new file mode 100644
index 0000000..fe94596
--- /dev/null
+++ b/src/Hook/LoadExtensionSchemaUpdates.php
@@ -0,0 +1,34 @@
+getDB()->getType();
+ $sqlFile = "$baseDir/sql/$dbType/PasskeyAuth.sql";
+
+ // Log that this hook is being called
+ wfDebugLog( 'PasskeyAuth', 'LoadExtensionSchemaUpdates hook called' );
+ wfDebugLog( 'PasskeyAuth', "Database type: $dbType" );
+ wfDebugLog( 'PasskeyAuth', "SQL file: $sqlFile" );
+ wfDebugLog( 'PasskeyAuth', 'SQL file exists: ' . ( file_exists( $sqlFile ) ? 'yes' : 'no' ) );
+
+ $updater->addExtensionTable(
+ 'passkey_credentials',
+ $sqlFile
+ );
+
+ wfDebugLog( 'PasskeyAuth', 'addExtensionTable called for passkey_credentials with SQL file' );
+ }
+}
diff --git a/src/Hook/PasskeyAuthUserAuthorization.php b/src/Hook/PasskeyAuthUserAuthorization.php
new file mode 100644
index 0000000..7647892
--- /dev/null
+++ b/src/Hook/PasskeyAuthUserAuthorization.php
@@ -0,0 +1,19 @@
+id = $id;
+ $this->userId = $userId;
+ $this->credentialId = $credentialId;
+ $this->publicKey = $publicKey;
+ $this->name = $name;
+ $this->counter = $counter;
+ $this->created = $created;
+ $this->lastUsed = $lastUsed;
+ $this->userAgent = $userAgent;
+ }
+
+ /**
+ * @return int|null
+ */
+ public function getId(): ?int {
+ return $this->id;
+ }
+
+ /**
+ * @param int $id
+ */
+ public function setId( int $id ): void {
+ $this->id = $id;
+ }
+
+ /**
+ * @return int
+ */
+ public function getUserId(): int {
+ return $this->userId;
+ }
+
+ /**
+ * @return string
+ */
+ public function getCredentialId(): string {
+ return $this->credentialId;
+ }
+
+ /**
+ * @return string
+ */
+ public function getPublicKey(): string {
+ return $this->publicKey;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getName(): ?string {
+ return $this->name;
+ }
+
+ /**
+ * @param string|null $name
+ */
+ public function setName( ?string $name ): void {
+ $this->name = $name;
+ }
+
+ /**
+ * @return int
+ */
+ public function getCounter(): int {
+ return $this->counter;
+ }
+
+ /**
+ * @param int $counter
+ */
+ public function setCounter( int $counter ): void {
+ $this->counter = $counter;
+ }
+
+ /**
+ * @return string
+ */
+ public function getCreated(): string {
+ return $this->created;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getLastUsed(): ?string {
+ return $this->lastUsed;
+ }
+
+ /**
+ * @param string $lastUsed
+ */
+ public function setLastUsed( string $lastUsed ): void {
+ $this->lastUsed = $lastUsed;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getUserAgent(): ?string {
+ return $this->userAgent;
+ }
+
+ /**
+ * Convert to array for API responses
+ *
+ * @return array
+ */
+ public function toArray(): array {
+ return [
+ 'id' => $this->id,
+ 'userId' => $this->userId,
+ 'credentialId' => $this->credentialId,
+ 'name' => $this->name,
+ 'counter' => $this->counter,
+ 'created' => $this->created,
+ 'lastUsed' => $this->lastUsed,
+ 'userAgent' => $this->userAgent,
+ ];
+ }
+
+ /**
+ * Create from database row
+ *
+ * @param \stdClass $row
+ * @return self
+ */
+ public static function newFromRow( \stdClass $row ): self {
+ return new self(
+ (int)$row->pc_id,
+ (int)$row->pc_user,
+ $row->pc_credential_id,
+ $row->pc_public_key,
+ $row->pc_name,
+ (int)$row->pc_counter,
+ $row->pc_created,
+ $row->pc_last_used,
+ $row->pc_user_agent
+ );
+ }
+}
diff --git a/src/Model/PasskeyCredential.php b/src/Model/PasskeyCredential.php
new file mode 100644
index 0000000..f985739
--- /dev/null
+++ b/src/Model/PasskeyCredential.php
@@ -0,0 +1,80 @@
+credentialId = $credentialId;
+ $this->publicKey = $publicKey;
+ $this->counter = $counter;
+ $this->attestationObject = $attestationObject;
+ $this->clientDataJSON = $clientDataJSON;
+ }
+
+ /**
+ * @return string
+ */
+ public function getCredentialId(): string {
+ return $this->credentialId;
+ }
+
+ /**
+ * @return string
+ */
+ public function getPublicKey(): string {
+ return $this->publicKey;
+ }
+
+ /**
+ * @return int
+ */
+ public function getCounter(): int {
+ return $this->counter;
+ }
+
+ /**
+ * @return string
+ */
+ public function getAttestationObject(): string {
+ return $this->attestationObject;
+ }
+
+ /**
+ * @return string
+ */
+ public function getClientDataJSON(): string {
+ return $this->clientDataJSON;
+ }
+}
diff --git a/src/Rest/PasskeyAuthenticationHandler.php b/src/Rest/PasskeyAuthenticationHandler.php
new file mode 100644
index 0000000..ad4930a
--- /dev/null
+++ b/src/Rest/PasskeyAuthenticationHandler.php
@@ -0,0 +1,167 @@
+webAuthnService = $webAuthnService;
+ $this->passkeyService = $passkeyService;
+ }
+
+ /**
+ * @return Response
+ */
+ public function execute(): Response {
+ $path = $this->getRequest()->getUri()->getPath();
+
+ // PHP 7.4 compatible string ending check
+ if ( substr( $path, -6 ) === '/begin' ) {
+ return $this->handleBegin();
+ } elseif ( substr( $path, -9 ) === '/complete' ) {
+ return $this->handleComplete();
+ }
+
+ return $this->getResponseFactory()->createHttpError( 404 );
+ }
+
+ /**
+ * Handle begin authentication
+ *
+ * @return Response
+ */
+ private function handleBegin(): Response {
+ $body = $this->getValidatedBody();
+ $userId = $body['userId'] ?? null;
+
+ $status = $this->passkeyService->beginAuthentication( $userId );
+
+ if ( !$status->isOK() ) {
+ return $this->getResponseFactory()->createHttpError( 400, [
+ 'error' => 'authentication-failed',
+ 'message' => \MediaWiki\Status\Status::wrap( $status )->getMessage()->text(),
+ ] );
+ }
+
+ $challenge = $status->getValue();
+
+ // Store challenge in session for verification
+ $session = $this->getSession();
+ $challengeString = $this->webAuthnService->getLastChallenge();
+ $session->set( 'passkeyauth_authentication_challenge', $challengeString );
+ $session->save();
+
+ return $this->getResponseFactory()->createJson( $challenge );
+ }
+
+ /**
+ * Handle complete authentication
+ *
+ * @return Response
+ */
+ private function handleComplete(): Response {
+ $body = $this->getValidatedBody();
+
+ $session = $this->getSession();
+ $storedChallenge = $session->get( 'passkeyauth_authentication_challenge' );
+
+ if ( !$storedChallenge ) {
+ return $this->getResponseFactory()->createHttpError( 400, [
+ 'error' => 'no-challenge',
+ 'message' => 'No authentication challenge found in session',
+ ] );
+ }
+
+ // Clear challenge from session
+ $session->remove( 'passkeyauth_authentication_challenge' );
+
+ $status = $this->passkeyService->completeAuthentication(
+ $body['credentialId'],
+ $body['clientDataJSON'],
+ $body['authenticatorData'],
+ $body['signature'],
+ $storedChallenge
+ );
+
+ if ( !$status->isOK() ) {
+ return $this->getResponseFactory()->createHttpError( 400, [
+ 'error' => 'authentication-failed',
+ 'message' => \MediaWiki\Status\Status::wrap( $status )->getMessage()->text(),
+ ] );
+ }
+
+ $passkey = $status->getValue();
+
+ // Set up the session for the authenticated user
+ $user = \User::newFromId( $passkey->getUserId() );
+ $session->setUser( $user );
+ $session->persist();
+
+ return $this->getResponseFactory()->createJson( [
+ 'success' => true,
+ 'userId' => $passkey->getUserId(),
+ ] );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getBodyParamSettings(): array {
+ return [
+ 'userId' => [
+ self::PARAM_SOURCE => 'body',
+ ParamValidator::PARAM_TYPE => 'integer',
+ ParamValidator::PARAM_REQUIRED => false,
+ ],
+ 'credentialId' => [
+ self::PARAM_SOURCE => 'body',
+ ParamValidator::PARAM_TYPE => 'string',
+ ParamValidator::PARAM_REQUIRED => false,
+ ],
+ 'clientDataJSON' => [
+ self::PARAM_SOURCE => 'body',
+ ParamValidator::PARAM_TYPE => 'string',
+ ParamValidator::PARAM_REQUIRED => false,
+ ],
+ 'authenticatorData' => [
+ self::PARAM_SOURCE => 'body',
+ ParamValidator::PARAM_TYPE => 'string',
+ ParamValidator::PARAM_REQUIRED => false,
+ ],
+ 'signature' => [
+ self::PARAM_SOURCE => 'body',
+ ParamValidator::PARAM_TYPE => 'string',
+ ParamValidator::PARAM_REQUIRED => false,
+ ],
+ ];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function needsWriteAccess(): bool {
+ return true;
+ }
+}
diff --git a/src/Rest/PasskeyManagementHandler.php b/src/Rest/PasskeyManagementHandler.php
new file mode 100644
index 0000000..be38dcf
--- /dev/null
+++ b/src/Rest/PasskeyManagementHandler.php
@@ -0,0 +1,96 @@
+passkeyService = $passkeyService;
+ }
+
+ /**
+ * @return Response
+ */
+ public function execute(): Response {
+ $user = $this->getAuthority()->getUser();
+
+ if ( !$user->isRegistered() ) {
+ return $this->getResponseFactory()->createHttpError( 401, [
+ 'error' => 'not-logged-in',
+ 'message' => 'You must be logged in to manage passkeys',
+ ] );
+ }
+
+ $method = $this->getRequest()->getMethod();
+
+ if ( $method === 'GET' ) {
+ return $this->handleList( $user );
+ } elseif ( $method === 'DELETE' ) {
+ return $this->handleDelete( $user );
+ }
+
+ return $this->getResponseFactory()->createHttpError( 405 );
+ }
+
+ /**
+ * Handle list passkeys
+ *
+ * @param \User $user
+ * @return Response
+ */
+ private function handleList( \User $user ): Response {
+ $passkeys = $this->passkeyService->getUserPasskeys( $user );
+
+ $data = array_map( static function ( $passkey ) {
+ return $passkey->toArray();
+ }, $passkeys );
+
+ return $this->getResponseFactory()->createJson( [
+ 'success' => true,
+ 'passkeys' => $data,
+ ] );
+ }
+
+ /**
+ * Handle delete passkey
+ *
+ * @param \User $user
+ * @return Response
+ */
+ private function handleDelete( \User $user ): Response {
+ $id = (int)$this->getRequest()->getPathParam( 'id' );
+
+ $status = $this->passkeyService->deletePasskey( $id, $user );
+
+ if ( !$status->isOK() ) {
+ return $this->getResponseFactory()->createHttpError( 400, [
+ 'error' => 'delete-failed',
+ 'message' => $status->getMessage()->text(),
+ ] );
+ }
+
+ return $this->getResponseFactory()->createJson( [
+ 'success' => true,
+ ] );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function needsWriteAccess(): bool {
+ return $this->getRequest()->getMethod() === 'DELETE';
+ }
+}
diff --git a/src/Rest/PasskeyRegistrationHandler.php b/src/Rest/PasskeyRegistrationHandler.php
new file mode 100644
index 0000000..18e70b7
--- /dev/null
+++ b/src/Rest/PasskeyRegistrationHandler.php
@@ -0,0 +1,161 @@
+webAuthnService = $webAuthnService;
+ $this->passkeyService = $passkeyService;
+ }
+
+ /**
+ * @return Response
+ */
+ public function execute(): Response {
+ $user = $this->getAuthority()->getUser();
+
+ if ( !$user->isRegistered() ) {
+ return $this->getResponseFactory()->createHttpError( 401, [
+ 'error' => 'not-logged-in',
+ 'message' => 'You must be logged in to register a passkey',
+ ] );
+ }
+
+ $path = $this->getRequest()->getUri()->getPath();
+
+ // PHP 7.4 compatible string ending check
+ if ( substr( $path, -6 ) === '/begin' ) {
+ return $this->handleBegin( $user );
+ } elseif ( substr( $path, -9 ) === '/complete' ) {
+ return $this->handleComplete( $user );
+ }
+
+ return $this->getResponseFactory()->createHttpError( 404 );
+ }
+
+ /**
+ * Handle begin registration
+ *
+ * @param \User $user
+ * @return Response
+ */
+ private function handleBegin( \User $user ): Response {
+ $status = $this->passkeyService->beginRegistration( $user );
+
+ if ( !$status->isOK() ) {
+ return $this->getResponseFactory()->createHttpError( 400, [
+ 'error' => 'registration-failed',
+ 'message' => \MediaWiki\Status\Status::wrap( $status )->getMessage()->text(),
+ ] );
+ }
+
+ $challenge = $status->getValue();
+
+ // Store challenge in session for verification
+ $session = $this->getSession();
+ $challengeString = $this->webAuthnService->getLastChallenge();
+ $session->set( 'passkeyauth_registration_challenge', $challengeString );
+ $session->save();
+
+ return $this->getResponseFactory()->createJson( $challenge );
+ }
+
+ /**
+ * Handle complete registration
+ *
+ * @param \User $user
+ * @return Response
+ */
+ private function handleComplete( \User $user ): Response {
+ $body = $this->getValidatedBody();
+
+ $session = $this->getSession();
+ $storedChallenge = $session->get( 'passkeyauth_registration_challenge' );
+
+ if ( !$storedChallenge ) {
+ return $this->getResponseFactory()->createHttpError( 400, [
+ 'error' => 'no-challenge',
+ 'message' => 'No registration challenge found in session',
+ ] );
+ }
+
+ // Clear challenge from session
+ $session->remove( 'passkeyauth_registration_challenge' );
+
+ $status = $this->passkeyService->completeRegistration(
+ $user,
+ $body['clientDataJSON'],
+ $body['attestationObject'],
+ $storedChallenge,
+ $body['name'] ?? null,
+ $this->getRequest()->getHeader( 'User-Agent' )[0] ?? null
+ );
+
+ if ( !$status->isOK() ) {
+ return $this->getResponseFactory()->createHttpError( 400, [
+ 'error' => 'registration-failed',
+ 'message' => \MediaWiki\Status\Status::wrap( $status )->getMessage()->text(),
+ ] );
+ }
+
+ $passkey = $status->getValue();
+
+ return $this->getResponseFactory()->createJson( [
+ 'success' => true,
+ 'passkey' => $passkey->toArray(),
+ ] );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getBodyParamSettings(): array {
+ return [
+ 'clientDataJSON' => [
+ self::PARAM_SOURCE => 'body',
+ ParamValidator::PARAM_TYPE => 'string',
+ ParamValidator::PARAM_REQUIRED => false,
+ ],
+ 'attestationObject' => [
+ self::PARAM_SOURCE => 'body',
+ ParamValidator::PARAM_TYPE => 'string',
+ ParamValidator::PARAM_REQUIRED => false,
+ ],
+ 'name' => [
+ self::PARAM_SOURCE => 'body',
+ ParamValidator::PARAM_TYPE => 'string',
+ ParamValidator::PARAM_REQUIRED => false,
+ ],
+ ];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function needsWriteAccess(): bool {
+ return true;
+ }
+}
diff --git a/src/Service/PasskeyService.php b/src/Service/PasskeyService.php
new file mode 100644
index 0000000..65629ff
--- /dev/null
+++ b/src/Service/PasskeyService.php
@@ -0,0 +1,348 @@
+passkeyStore = $passkeyStore;
+ $this->webAuthnService = $webAuthnService;
+ $this->validationService = $validationService;
+ $this->logger = $logger;
+ }
+
+ /**
+ * Start passkey registration
+ *
+ * @param UserIdentity $user
+ * @return StatusValue Contains challenge data on success
+ */
+ public function beginRegistration( UserIdentity $user ): StatusValue {
+ $validation = $this->validationService->validateEnabled();
+ if ( !$validation->isOK() ) {
+ return $validation;
+ }
+
+ $currentCount = $this->passkeyStore->countPasskeysByUserId( $user->getId() );
+ $validation = $this->validationService->validatePasskeyLimit( $currentCount );
+ if ( !$validation->isOK() ) {
+ return $validation;
+ }
+
+ // Get existing credentials to exclude
+ $existingPasskeys = $this->passkeyStore->getPasskeysByUserId( $user->getId() );
+ $excludeCredentials = array_map( static function ( Passkey $passkey ) {
+ return base64_decode( $passkey->getCredentialId() );
+ }, $existingPasskeys );
+
+ try {
+ $challenge = $this->webAuthnService->generateRegistrationChallenge(
+ $user->getId(),
+ $user->getName(),
+ $excludeCredentials
+ );
+
+ $this->logger->info( 'Registration challenge generated', [
+ 'userId' => $user->getId(),
+ ] );
+
+ return StatusValue::newGood( $challenge );
+ } catch ( \Exception $e ) {
+ $this->logger->error( 'Failed to generate registration challenge', [
+ 'error' => $e->getMessage(),
+ 'userId' => $user->getId(),
+ ] );
+
+ return StatusValue::newFatal( 'passkeyauth-error-registration-failed' );
+ }
+ }
+
+ /**
+ * Complete passkey registration
+ *
+ * @param UserIdentity $user
+ * @param string $clientDataJSON
+ * @param string $attestationObject
+ * @param string $challenge
+ * @param string|null $name
+ * @param string|null $userAgent
+ * @return StatusValue Contains Passkey on success
+ */
+ public function completeRegistration(
+ UserIdentity $user,
+ string $clientDataJSON,
+ string $attestationObject,
+ string $challenge,
+ ?string $name,
+ ?string $userAgent
+ ): StatusValue {
+ $validation = $this->validationService->validatePasskeyName( $name );
+ if ( !$validation->isOK() ) {
+ return $validation;
+ }
+
+ try {
+ $result = $this->webAuthnService->processRegistration(
+ $clientDataJSON,
+ $attestationObject,
+ $challenge,
+ false
+ );
+
+ $passkey = new Passkey(
+ null,
+ $user->getId(),
+ $result['credentialId'],
+ $result['publicKeyPem'],
+ $name,
+ $result['counter'],
+ wfTimestampNow(),
+ null,
+ $userAgent
+ );
+
+ $status = $this->passkeyStore->createPasskey( $passkey );
+
+ if ( $status->isOK() ) {
+ $this->logger->info( 'Passkey registration completed', [
+ 'userId' => $user->getId(),
+ 'passkeyId' => $passkey->getId(),
+ ] );
+ }
+
+ return $status;
+ } catch ( \Exception $e ) {
+ $this->logger->error( 'Failed to complete registration', [
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString(),
+ 'userId' => $user->getId(),
+ ] );
+
+ // Return detailed error for debugging
+ return StatusValue::newFatal(
+ 'passkeyauth-error-registration-failed-detailed',
+ $e->getMessage()
+ );
+ }
+ }
+
+ /**
+ * Begin passkey authentication
+ *
+ * @param int|null $userId Optional user ID to restrict authentication
+ * @return StatusValue Contains challenge data on success
+ */
+ public function beginAuthentication( ?int $userId = null ): StatusValue {
+ $validation = $this->validationService->validateEnabled();
+ if ( !$validation->isOK() ) {
+ return $validation;
+ }
+
+ $allowCredentials = [];
+ if ( $userId !== null ) {
+ $passkeys = $this->passkeyStore->getPasskeysByUserId( $userId );
+ $allowCredentials = array_map( static function ( Passkey $passkey ) {
+ return base64_decode( $passkey->getCredentialId() );
+ }, $passkeys );
+
+ if ( empty( $allowCredentials ) ) {
+ return StatusValue::newFatal( 'passkeyauth-error-no-passkeys' );
+ }
+ }
+
+ try {
+ $challenge = $this->webAuthnService->generateAuthenticationChallenge( $allowCredentials );
+
+ $this->logger->info( 'Authentication challenge generated', [
+ 'userId' => $userId,
+ ] );
+
+ return StatusValue::newGood( $challenge );
+ } catch ( \Exception $e ) {
+ $this->logger->error( 'Failed to generate authentication challenge', [
+ 'error' => $e->getMessage(),
+ 'userId' => $userId,
+ ] );
+
+ return StatusValue::newFatal( 'passkeyauth-error-authentication-failed' );
+ }
+ }
+
+ /**
+ * Complete passkey authentication
+ *
+ * @param string $credentialId Base64url-encoded credential ID from client
+ * @param string $clientDataJSON
+ * @param string $authenticatorData
+ * @param string $signature
+ * @param string $challenge
+ * @return StatusValue Contains Passkey on success
+ */
+ public function completeAuthentication(
+ string $credentialId,
+ string $clientDataJSON,
+ string $authenticatorData,
+ string $signature,
+ string $challenge
+ ): StatusValue {
+ // Convert base64url to standard base64 for database lookup
+ // The credential ID is sent as base64url from the client but stored as base64
+ $credentialIdBase64 = $this->base64urlToBase64( $credentialId );
+
+ $passkey = $this->passkeyStore->getPasskeyByCredentialId( $credentialIdBase64 );
+
+ if ( $passkey === null ) {
+ $this->logger->warning( 'Authentication attempted with unknown credential', [
+ 'credentialId' => $credentialId,
+ 'credentialIdBase64' => $credentialIdBase64,
+ ] );
+
+ return StatusValue::newFatal( 'passkeyauth-error-invalid-credential' );
+ }
+
+ try {
+ $newCounter = $this->webAuthnService->processAuthentication(
+ $clientDataJSON,
+ $authenticatorData,
+ $signature,
+ $passkey->getPublicKey(),
+ $challenge,
+ $passkey->getCounter(),
+ false
+ );
+
+ $validation = $this->validationService->validateCounter( $newCounter, $passkey->getCounter() );
+ if ( !$validation->isOK() ) {
+ $this->logger->error( 'Counter validation failed', [
+ 'passkeyId' => $passkey->getId(),
+ 'storedCounter' => $passkey->getCounter(),
+ 'newCounter' => $newCounter,
+ ] );
+
+ return $validation;
+ }
+
+ $passkey->setCounter( $newCounter );
+ $passkey->setLastUsed( wfTimestampNow() );
+ $this->passkeyStore->updatePasskey( $passkey );
+
+ $this->logger->info( 'Passkey authentication completed', [
+ 'userId' => $passkey->getUserId(),
+ 'passkeyId' => $passkey->getId(),
+ ] );
+
+ return StatusValue::newGood( $passkey );
+ } catch ( \Exception $e ) {
+ $this->logger->error( 'Failed to complete authentication', [
+ 'error' => $e->getMessage(),
+ 'credentialId' => $credentialId,
+ ] );
+
+ return StatusValue::newFatal( 'passkeyauth-error-authentication-failed' );
+ }
+ }
+
+ /**
+ * Get all passkeys for a user
+ *
+ * @param UserIdentity $user
+ * @return Passkey[]
+ */
+ public function getUserPasskeys( UserIdentity $user ): array {
+ return $this->passkeyStore->getPasskeysByUserId( $user->getId() );
+ }
+
+ /**
+ * Delete a passkey
+ *
+ * @param int $passkeyId
+ * @param UserIdentity $user
+ * @return StatusValue
+ */
+ public function deletePasskey( int $passkeyId, UserIdentity $user ): StatusValue {
+ $passkey = $this->passkeyStore->getPasskeyById( $passkeyId );
+
+ if ( $passkey === null ) {
+ return StatusValue::newFatal( 'passkeyauth-error-passkey-not-found' );
+ }
+
+ if ( $passkey->getUserId() !== $user->getId() ) {
+ $this->logger->warning( 'User attempted to delete another user\'s passkey', [
+ 'userId' => $user->getId(),
+ 'passkeyUserId' => $passkey->getUserId(),
+ 'passkeyId' => $passkeyId,
+ ] );
+
+ return StatusValue::newFatal( 'passkeyauth-error-permission-denied' );
+ }
+
+ return $this->passkeyStore->deletePasskey( $passkeyId );
+ }
+
+ /**
+ * Check if user has any passkeys
+ *
+ * @param UserIdentity $user
+ * @return bool
+ */
+ public function userHasPasskeys( UserIdentity $user ): bool {
+ return $this->passkeyStore->userHasPasskeys( $user->getId() );
+ }
+
+ /**
+ * Convert base64url string to standard base64
+ *
+ * @param string $base64url Base64url-encoded string
+ * @return string Standard base64-encoded string
+ */
+ private function base64urlToBase64( string $base64url ): string {
+ // Replace URL-safe characters with standard base64 characters
+ $base64 = strtr( $base64url, '-_', '+/' );
+
+ // Add padding if needed
+ $remainder = strlen( $base64 ) % 4;
+ if ( $remainder ) {
+ $base64 .= str_repeat( '=', 4 - $remainder );
+ }
+
+ return $base64;
+ }
+}
+
diff --git a/src/Service/PasskeyValidationService.php b/src/Service/PasskeyValidationService.php
new file mode 100644
index 0000000..a08593b
--- /dev/null
+++ b/src/Service/PasskeyValidationService.php
@@ -0,0 +1,107 @@
+config = $config;
+ }
+
+ /**
+ * Validate passkey name
+ *
+ * @param string|null $name
+ * @return StatusValue
+ */
+ public function validatePasskeyName( ?string $name ): StatusValue {
+ if ( $name === null || $name === '' ) {
+ return StatusValue::newGood();
+ }
+
+ if ( strlen( $name ) > 255 ) {
+ return StatusValue::newFatal( 'passkeyauth-error-name-too-long' );
+ }
+
+ if ( preg_match( '/[<>]/', $name ) ) {
+ return StatusValue::newFatal( 'passkeyauth-error-name-invalid-chars' );
+ }
+
+ return StatusValue::newGood();
+ }
+
+ /**
+ * Validate that user can create another passkey
+ *
+ * @param int $currentCount
+ * @return StatusValue
+ */
+ public function validatePasskeyLimit( int $currentCount ): StatusValue {
+ $maxCredentials = $this->config->get( 'PasskeyAuthMaxCredentialsPerUser' );
+
+ if ( $currentCount >= $maxCredentials ) {
+ return StatusValue::newFatal( 'passkeyauth-error-too-many-passkeys', $maxCredentials );
+ }
+
+ return StatusValue::newGood();
+ }
+
+ /**
+ * Validate counter to prevent replay attacks
+ *
+ * @param int $newCounter
+ * @param int $storedCounter
+ * @return StatusValue
+ */
+ public function validateCounter( int $newCounter, int $storedCounter ): StatusValue {
+ if ( $newCounter <= $storedCounter && $storedCounter !== 0 ) {
+ return StatusValue::newFatal( 'passkeyauth-error-counter-mismatch' );
+ }
+
+ return StatusValue::newGood();
+ }
+
+ /**
+ * Validate secure context requirement
+ *
+ * @param \WebRequest $request
+ * @return StatusValue
+ */
+ public function validateSecureContext( \WebRequest $request ): StatusValue {
+ if ( !$this->config->get( 'PasskeyAuthRequireSecureContext' ) ) {
+ return StatusValue::newGood();
+ }
+
+ $protocol = $request->getProtocol();
+ if ( $protocol !== 'https' ) {
+ return StatusValue::newFatal( 'passkeyauth-error-insecure-context' );
+ }
+
+ return StatusValue::newGood();
+ }
+
+ /**
+ * Check if extension is enabled
+ *
+ * @return StatusValue
+ */
+ public function validateEnabled(): StatusValue {
+ if ( !$this->config->get( 'PasskeyAuthEnabled' ) ) {
+ return StatusValue::newFatal( 'passkeyauth-error-disabled' );
+ }
+
+ return StatusValue::newGood();
+ }
+}
diff --git a/src/Service/WebAuthnService.php b/src/Service/WebAuthnService.php
new file mode 100644
index 0000000..1a10b95
--- /dev/null
+++ b/src/Service/WebAuthnService.php
@@ -0,0 +1,273 @@
+config = $config;
+ $this->logger = $logger;
+ }
+
+ /**
+ * Get the WebAuthn instance
+ *
+ * @return WebAuthn
+ */
+ private function getWebAuthn(): WebAuthn {
+ if ( $this->webAuthn === null ) {
+ $rpName = $this->config->get( 'PasskeyAuthRPName' ) ?: $this->config->get( 'Sitename' );
+ $rpId = $this->config->get( 'PasskeyAuthRPID' ) ?: $this->extractRpId();
+
+ $this->webAuthn = new WebAuthn( $rpName, $rpId );
+ }
+
+ return $this->webAuthn;
+ }
+
+ /**
+ * Get the last generated challenge
+ *
+ * @return string|null Base64url-encoded challenge
+ */
+ public function getLastChallenge(): ?string {
+ if ( $this->webAuthn === null ) {
+ return null;
+ }
+
+ $challenge = $this->webAuthn->getChallenge();
+ return $challenge ? $this->base64urlEncode( $challenge->getBinaryString() ) : null;
+ }
+
+ /**
+ * Extract RP ID from server configuration
+ *
+ * @return string
+ */
+ private function extractRpId(): string {
+ $server = $this->config->get( 'Server' );
+ $parsed = parse_url( $server );
+ return $parsed['host'] ?? 'localhost';
+ }
+
+ /**
+ * Generate registration challenge
+ *
+ * @param int $userId
+ * @param string $userName
+ * @param array $excludeCredentials Array of credential IDs to exclude
+ * @return array
+ */
+ public function generateRegistrationChallenge(
+ int $userId,
+ string $userName,
+ array $excludeCredentials = []
+ ): array {
+ $webAuthn = $this->getWebAuthn();
+
+ // Enable base64url encoding for ByteBuffer objects
+ ByteBuffer::$useBase64UrlEncoding = true;
+
+ $createArgs = $webAuthn->getCreateArgs(
+ base64_encode( (string)$userId ),
+ $userName,
+ $userName,
+ $this->config->get( 'PasskeyAuthTimeout' ) / 1000,
+ $this->config->get( 'PasskeyAuthUserVerification' ) === 'required',
+ // Don't require resident key
+ false,
+ $this->config->get( 'PasskeyAuthAuthenticatorAttachment' ) === 'platform',
+ $this->config->get( 'PasskeyAuthAuthenticatorAttachment' ) === 'cross-platform',
+ $excludeCredentials
+ );
+
+ $this->logger->debug( 'Generated registration challenge', [
+ 'userId' => $userId,
+ 'userName' => $userName,
+ ] );
+
+ // Convert stdClass to array for compatibility
+ return json_decode( json_encode( $createArgs ), true );
+ }
+
+ /**
+ * Generate authentication challenge
+ *
+ * @param array $allowCredentials Array of credential IDs to allow
+ * @return array
+ */
+ public function generateAuthenticationChallenge( array $allowCredentials = [] ): array {
+ $webAuthn = $this->getWebAuthn();
+
+ // Enable base64url encoding for ByteBuffer objects
+ ByteBuffer::$useBase64UrlEncoding = true;
+
+ $getArgs = $webAuthn->getGetArgs(
+ $allowCredentials,
+ $this->config->get( 'PasskeyAuthTimeout' ) / 1000,
+ $this->config->get( 'PasskeyAuthUserVerification' ) === 'required',
+ $this->config->get( 'PasskeyAuthAuthenticatorAttachment' ) === 'platform',
+ $this->config->get( 'PasskeyAuthAuthenticatorAttachment' ) === 'cross-platform'
+ );
+
+ $this->logger->debug( 'Generated authentication challenge', [
+ 'credentialCount' => count( $allowCredentials ),
+ ] );
+
+ // Convert stdClass to array for compatibility
+ return json_decode( json_encode( $getArgs ), true );
+ }
+
+ /**
+ * Process registration response
+ *
+ * @param string $clientDataJSON Base64url-encoded client data JSON
+ * @param string $attestationObject Base64url-encoded attestation object
+ * @param string $challenge Base64url-encoded challenge
+ * @param bool $requireUserVerification
+ * @return array Array with credentialId, publicKeyPem, counter
+ * @throws \Exception
+ */
+ public function processRegistration(
+ string $clientDataJSON,
+ string $attestationObject,
+ string $challenge,
+ bool $requireUserVerification = false
+ ): array {
+ $webAuthn = $this->getWebAuthn();
+
+ // Decode base64url strings to binary
+ $clientDataBinary = $this->base64urlDecode( $clientDataJSON );
+ $attestationBinary = $this->base64urlDecode( $attestationObject );
+ $challengeBinary = $this->base64urlDecode( $challenge );
+
+ $data = $webAuthn->processCreate(
+ $clientDataBinary,
+ $attestationBinary,
+ $challengeBinary,
+ $requireUserVerification,
+ true, // Check if all required fields are present
+ true // Check if the origin is correct
+ );
+
+ $this->logger->debug( 'Processed registration response', [
+ 'credentialId' => bin2hex( $data->credentialId ),
+ ] );
+
+ return [
+ 'credentialId' => base64_encode( $data->credentialId ),
+ 'publicKeyPem' => $data->credentialPublicKey,
+ 'counter' => $data->signCounter ?? 0,
+ ];
+ }
+
+ /**
+ * Process authentication response
+ *
+ * @param string $clientDataJSON Base64url-encoded client data JSON
+ * @param string $authenticatorData Base64url-encoded authenticator data
+ * @param string $signature Base64url-encoded signature
+ * @param string $publicKeyPem
+ * @param string $challenge Base64url-encoded challenge
+ * @param int $prevCounter
+ * @param bool $requireUserVerification
+ * @return int New counter value
+ * @throws \Exception
+ */
+ public function processAuthentication(
+ string $clientDataJSON,
+ string $authenticatorData,
+ string $signature,
+ string $publicKeyPem,
+ string $challenge,
+ int $prevCounter = 0,
+ bool $requireUserVerification = false
+ ): int {
+ $webAuthn = $this->getWebAuthn();
+
+ // Decode base64url strings to binary
+ $clientDataBinary = $this->base64urlDecode( $clientDataJSON );
+ $authenticatorDataBinary = $this->base64urlDecode( $authenticatorData );
+ $signatureBinary = $this->base64urlDecode( $signature );
+ $challengeBinary = $this->base64urlDecode( $challenge );
+
+ $webAuthn->processGet(
+ $clientDataBinary,
+ $authenticatorDataBinary,
+ $signatureBinary,
+ $publicKeyPem,
+ $challengeBinary,
+ $prevCounter,
+ $requireUserVerification,
+ true // Check if the origin is correct
+ );
+
+ // Extract new counter from authenticator data (already binary)
+ $counter = unpack( 'N', substr( $authenticatorDataBinary, 33, 4 ) )[1];
+
+ $this->logger->debug( 'Processed authentication response', [
+ 'prevCounter' => $prevCounter,
+ 'newCounter' => $counter,
+ ] );
+
+ return $counter;
+ }
+
+ /**
+ * Decode base64url string to binary
+ *
+ * @param string $data Base64url-encoded string
+ * @return string Binary data
+ */
+ private function base64urlDecode( string $data ): string {
+ // Replace URL-safe characters with standard base64 characters
+ $base64 = strtr( $data, '-_', '+/' );
+
+ // Add padding if needed
+ $remainder = strlen( $base64 ) % 4;
+ if ( $remainder ) {
+ $base64 .= str_repeat( '=', 4 - $remainder );
+ }
+
+ return base64_decode( $base64 );
+ }
+
+ /**
+ * Encode binary data to base64url string
+ *
+ * @param string $data Binary data
+ * @return string Base64url-encoded string
+ */
+ private function base64urlEncode( string $data ): string {
+ // Encode to base64 and make it URL-safe
+ $base64 = base64_encode( $data );
+ $base64url = strtr( $base64, '+/', '-_' );
+
+ // Remove padding
+ return rtrim( $base64url, '=' );
+ }
+}
diff --git a/src/ServiceWiring.php b/src/ServiceWiring.php
new file mode 100644
index 0000000..72efc3f
--- /dev/null
+++ b/src/ServiceWiring.php
@@ -0,0 +1,41 @@
+ static function ( MediaWikiServices $services ) {
+ return new PasskeyStore(
+ $services->getDBLoadBalancer(),
+ LoggerFactory::getInstance( 'PasskeyAuth' )
+ );
+ },
+
+ 'PasskeyAuth.WebAuthnService' => static function ( MediaWikiServices $services ) {
+ return new WebAuthnService(
+ $services->getMainConfig(),
+ LoggerFactory::getInstance( 'PasskeyAuth' )
+ );
+ },
+
+ 'PasskeyAuth.PasskeyValidationService' => static function ( MediaWikiServices $services ) {
+ return new PasskeyValidationService(
+ $services->getMainConfig()
+ );
+ },
+
+ 'PasskeyAuth.PasskeyService' => static function ( MediaWikiServices $services ) {
+ return new PasskeyService(
+ $services->get( 'PasskeyAuth.PasskeyStore' ),
+ $services->get( 'PasskeyAuth.WebAuthnService' ),
+ $services->get( 'PasskeyAuth.PasskeyValidationService' ),
+ LoggerFactory::getInstance( 'PasskeyAuth' )
+ );
+ },
+];
diff --git a/src/Special/PasskeyAuth.alias.php b/src/Special/PasskeyAuth.alias.php
new file mode 100644
index 0000000..25500c6
--- /dev/null
+++ b/src/Special/PasskeyAuth.alias.php
@@ -0,0 +1,13 @@
+ [ 'CreatePasskey' ],
+ 'ManagePasskeys' => [ 'ManagePasskeys' ],
+];
diff --git a/src/Special/SpecialCreatePasskey.php b/src/Special/SpecialCreatePasskey.php
new file mode 100644
index 0000000..8578aa1
--- /dev/null
+++ b/src/Special/SpecialCreatePasskey.php
@@ -0,0 +1,62 @@
+passkeyService = $passkeyService;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function execute( $subPage ) {
+ $this->setHeaders();
+ $this->checkPermissions();
+
+ $user = $this->getUser();
+ if ( !$user->isRegistered() ) {
+ $this->getOutput()->addWikiMsg( 'passkeyauth-error-not-logged-in' );
+ return;
+ }
+
+ $this->getOutput()->addModules( 'ext.passkeyAuth.createPasskey' );
+ $this->getOutput()->addHTML( $this->buildForm() );
+ }
+
+ /**
+ * Build the passkey creation form
+ *
+ * @return string HTML
+ */
+ private function buildForm(): string {
+ $html = Html::openElement( 'div', [ 'id' => 'passkeyauth-create-container' ] );
+ $html .= Html::element( 'p', [], $this->msg( 'passkeyauth-createpasskey-intro' )->text() );
+ $html .= Html::element( 'div', [ 'id' => 'passkeyauth-create-widget' ] );
+ $html .= Html::closeElement( 'div' );
+
+ return $html;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function getGroupName() {
+ return 'users';
+ }
+}
diff --git a/src/Special/SpecialManagePasskeys.php b/src/Special/SpecialManagePasskeys.php
new file mode 100644
index 0000000..fb884c3
--- /dev/null
+++ b/src/Special/SpecialManagePasskeys.php
@@ -0,0 +1,62 @@
+passkeyService = $passkeyService;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function execute( $subPage ) {
+ $this->setHeaders();
+ $this->checkPermissions();
+
+ $user = $this->getUser();
+ if ( !$user->isRegistered() ) {
+ $this->getOutput()->addWikiMsg( 'passkeyauth-error-not-logged-in' );
+ return;
+ }
+
+ $this->getOutput()->addModules( 'ext.passkeyAuth.managePasskeys' );
+ $this->getOutput()->addHTML( $this->buildInterface() );
+ }
+
+ /**
+ * Build the passkey management interface
+ *
+ * @return string HTML
+ */
+ private function buildInterface(): string {
+ $html = Html::openElement( 'div', [ 'id' => 'passkeyauth-manage-container' ] );
+ $html .= Html::element( 'p', [], $this->msg( 'passkeyauth-managepasskeys-intro' )->text() );
+ $html .= Html::element( 'div', [ 'id' => 'passkeyauth-manage-widget' ] );
+ $html .= Html::closeElement( 'div' );
+
+ return $html;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function getGroupName() {
+ return 'users';
+ }
+}
diff --git a/tests/phpunit/unit/Model/PasskeyTest.php b/tests/phpunit/unit/Model/PasskeyTest.php
new file mode 100644
index 0000000..e66e5a3
--- /dev/null
+++ b/tests/phpunit/unit/Model/PasskeyTest.php
@@ -0,0 +1,111 @@
+assertSame( 1, $passkey->getId() );
+ $this->assertSame( 123, $passkey->getUserId() );
+ $this->assertSame( 'credential-id', $passkey->getCredentialId() );
+ $this->assertSame( 'public-key-data', $passkey->getPublicKey() );
+ $this->assertSame( 'My Laptop', $passkey->getName() );
+ $this->assertSame( 0, $passkey->getCounter() );
+ $this->assertSame( '20231026120000', $passkey->getCreated() );
+ $this->assertNull( $passkey->getLastUsed() );
+ $this->assertSame( 'Mozilla/5.0', $passkey->getUserAgent() );
+ }
+
+ public function testSetters() {
+ $passkey = new Passkey(
+ null,
+ 123,
+ 'credential-id',
+ 'public-key-data',
+ 'My Laptop',
+ 0,
+ '20231026120000',
+ null,
+ 'Mozilla/5.0'
+ );
+
+ $passkey->setId( 5 );
+ $this->assertSame( 5, $passkey->getId() );
+
+ $passkey->setName( 'My Phone' );
+ $this->assertSame( 'My Phone', $passkey->getName() );
+
+ $passkey->setCounter( 10 );
+ $this->assertSame( 10, $passkey->getCounter() );
+
+ $passkey->setLastUsed( '20231027100000' );
+ $this->assertSame( '20231027100000', $passkey->getLastUsed() );
+ }
+
+ public function testToArray() {
+ $passkey = new Passkey(
+ 1,
+ 123,
+ 'credential-id',
+ 'public-key-data',
+ 'My Laptop',
+ 0,
+ '20231026120000',
+ '20231027100000',
+ 'Mozilla/5.0'
+ );
+
+ $array = $passkey->toArray();
+
+ $this->assertIsArray( $array );
+ $this->assertSame( 1, $array['id'] );
+ $this->assertSame( 123, $array['userId'] );
+ $this->assertSame( 'credential-id', $array['credentialId'] );
+ $this->assertSame( 'My Laptop', $array['name'] );
+ $this->assertSame( 0, $array['counter'] );
+ $this->assertSame( '20231026120000', $array['created'] );
+ $this->assertSame( '20231027100000', $array['lastUsed'] );
+ $this->assertSame( 'Mozilla/5.0', $array['userAgent'] );
+ $this->assertArrayNotHasKey( 'publicKey', $array );
+ }
+
+ public function testNewFromRow() {
+ $row = (object)[
+ 'pc_id' => 1,
+ 'pc_user' => 123,
+ 'pc_credential_id' => 'credential-id',
+ 'pc_public_key' => 'public-key-data',
+ 'pc_name' => 'My Laptop',
+ 'pc_counter' => 0,
+ 'pc_created' => '20231026120000',
+ 'pc_last_used' => '20231027100000',
+ 'pc_user_agent' => 'Mozilla/5.0',
+ ];
+
+ $passkey = Passkey::newFromRow( $row );
+
+ $this->assertInstanceOf( Passkey::class, $passkey );
+ $this->assertSame( 1, $passkey->getId() );
+ $this->assertSame( 123, $passkey->getUserId() );
+ $this->assertSame( 'credential-id', $passkey->getCredentialId() );
+ $this->assertSame( 'My Laptop', $passkey->getName() );
+ }
+}
diff --git a/tests/phpunit/unit/Service/PasskeyValidationServiceTest.php b/tests/phpunit/unit/Service/PasskeyValidationServiceTest.php
new file mode 100644
index 0000000..e5d2a8a
--- /dev/null
+++ b/tests/phpunit/unit/Service/PasskeyValidationServiceTest.php
@@ -0,0 +1,123 @@
+ true,
+ 'PasskeyAuthRequireSecureContext' => true,
+ 'PasskeyAuthMaxCredentialsPerUser' => 10,
+ ];
+
+ return new PasskeyValidationService(
+ new HashConfig( array_merge( $defaultConfig, $config ) )
+ );
+ }
+
+ public function testValidatePasskeyName_ValidName() {
+ $service = $this->getService();
+ $status = $service->validatePasskeyName( 'My Laptop' );
+
+ $this->assertTrue( $status->isOK() );
+ }
+
+ public function testValidatePasskeyName_EmptyName() {
+ $service = $this->getService();
+ $status = $service->validatePasskeyName( '' );
+
+ $this->assertTrue( $status->isOK() );
+ }
+
+ public function testValidatePasskeyName_NullName() {
+ $service = $this->getService();
+ $status = $service->validatePasskeyName( null );
+
+ $this->assertTrue( $status->isOK() );
+ }
+
+ public function testValidatePasskeyName_TooLong() {
+ $service = $this->getService();
+ $status = $service->validatePasskeyName( str_repeat( 'a', 256 ) );
+
+ $this->assertFalse( $status->isOK() );
+ }
+
+ public function testValidatePasskeyName_InvalidChars() {
+ $service = $this->getService();
+ $status = $service->validatePasskeyName( 'My |